Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ and the live Diff settings preview.
Content, rendered Preview, History, Compare, Blame, image, and directory
modes. Content uses Pierre's lightweight edit mode; unsaved drafts survive
navigation during the app session and reach disk only through Save or `Mod+S`;
Discard changes resets the current buffer without writing it.
toolbar Undo/Redo controls share Pierre's structure-aware keyboard history,
and Discard changes resets the current buffer without writing it.
Multiple terminals run at the repository
root and keep output, scrollback, and selection across view, repository, and
workspace switches, pane splits, and resizes, and full-screen terminal apps receive the fitted PTY
Expand Down
6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2487,6 +2487,12 @@ remain explicit through Save / Mod+S and retain the optimistic stale-write guard
the editor's Discard changes action resets the session buffer and reloads disk
content without issuing a write.

**Editor undo/redo controls shipped (2026-08-06):** Work Content's toolbar now
exposes Pierre's structure-aware undo history through state-aware Undo and Redo
buttons. The controls track the same history as Mod+Z, Mod+Shift+Z, and Ctrl+Y,
and reset when Strand intentionally rebuilds the editor after a clean external
refresh or Discard.

---

## Cross-cutting tracks (run in parallel with all milestones)
Expand Down
6 changes: 5 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,11 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git-
and a refresh that finishes after typing starts cannot replace the draft
(`ContentTab` loaded-source/dirty guards). Mod+F uses Pierre's editor search
and replace while editing; read-only and palette-triggered searches retain
Strand's wrap-around `FileSearchBar` + `searchFileText` path.
Strand's wrap-around `FileSearchBar` + `searchFileText` path. Toolbar Undo
and Redo controls bind directly to Pierre's structure-aware history, mirror
its `canUndo` / `canRedo` state, and stay aligned with the existing
Mod+Z / Mod+Shift+Z / Ctrl+Y editor shortcuts (`PierreFileEditor` history
bridge + `ContentTab` controls).
- ☑ Preview tab — rendered view for renderable text files, tab only offered
for them (`PreviewTab` in `FileView.tsx`): SVG through the image pipeline
(`ImagePreview`, data-URL `<img>`), markdown through `lib/markdown.tsx`
Expand Down
5 changes: 5 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,11 @@ text separately for optimistic writes so the core can restore the original
line-ending convention on save. Discarding editor changes is a session-buffer
operation: clear the stored draft, rebuild Pierre from the last-read text, then
refresh from disk; never implement it through a working-tree write.
Expose undo/redo through Pierre's public `Editor` instance and its `canUndo` /
`canRedo` flags; never maintain a parallel history over the session draft. The
toolbar and Pierre's keymap must walk the same stack, and an intentional editor
rebuild (clean external refresh or Discard) must clear the exposed handle until
the replacement document attaches.

**Windows discard keeps libgit2 fast and falls back only for its path ceiling
(2026-07-20).** `git2::Repository::checkout_index` may inspect an unrelated
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type IconName =
| 'history' | 'compare' | 'blame' | 'content' | 'terminal' | 'external' | 'eye' | 'sparkle'
| 'split' | 'unified' | 'rebase' | 'circle' | 'lock' | 'star' | 'gpg' | 'warning' | 'settings'
| 'trash' | 'workspace'
| 'bell' | 'save'
| 'bell' | 'save' | 'undo' | 'redo'
| 'win-min' | 'win-max' | 'win-restore' | 'win-close';

interface Props extends Omit<SVGProps<SVGSVGElement>, 'name' | 'stroke'> {
Expand Down Expand Up @@ -51,6 +51,8 @@ export function Icon({ name, size = 14, stroke = 1.5, ...rest }: Props) {
case 'workspace': return <svg {...p}><path d="M8 1.8 14.5 5 8 8.2 1.5 5 8 1.8Z"/><path d="M1.5 8 8 11.2 14.5 8"/><path d="M1.5 11 8 14.2 14.5 11"/></svg>;
case 'bell': return <svg {...p}><path d="M3 11h10l-1.2-1.6V6a3.8 3.8 0 0 0-7.6 0v3.4L3 11Z"/><path d="M6.5 13a1.6 1.6 0 0 0 3 0"/></svg>;
case 'save': return <svg {...p}><path d="M2 2h10l2 2v10H2V2Z"/><path d="M5 2v4h6V2M5 14V9h6v5"/></svg>;
case 'undo': return <svg {...p}><path d="M3 7h7a4 4 0 0 1 4 4v1"/><path d="m6 4-3 3 3 3"/></svg>;
case 'redo': return <svg {...p}><path d="M13 7H6a4 4 0 0 0-4 4v1"/><path d="m10 4 3 3-3 3"/></svg>;
case 'folder-open': return <svg {...p}><path d="M1.5 4a1 1 0 0 1 1-1h3l1.5 1.5h6.5a1 1 0 0 1 1 1v1.5H1.5V4Z"/><path d="M1.5 6h13l-1.2 6.6a1 1 0 0 1-1 .9H3.7a1 1 0 0 1-1-.9L1.5 6Z"/></svg>;
case 'changes': return <svg {...p}><circle cx="8" cy="8" r="6.2"/><path d="M5 8h6M8 5v6"/></svg>;
case 'search': return <svg {...p}><circle cx="7" cy="7" r="4.5"/><path d="M10.4 10.4l3.1 3.1"/></svg>;
Expand Down
27 changes: 24 additions & 3 deletions ui/src/components/PierreFileEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import type { FileOptions } from '@pierre/diffs/react';
import { EditProvider, File } from '@pierre/diffs/react';
import { Editor, type EditorOptions } from '@pierre/diffs/edit';
Expand All @@ -10,6 +10,14 @@ interface PierreFileEditorProps {
selectedLine: number | null;
text: string;
onChange(text: string): void;
onHistoryChange(editor: PierreEditorHistory | null): void;
}

export interface PierreEditorHistory {
readonly canUndo: boolean;
readonly canRedo: boolean;
undo(): void;
redo(): void;
}

function createEditor(options: EditorOptions<undefined>): Editor<undefined> {
Expand All @@ -24,13 +32,26 @@ export default function PierreFileEditor({
selectedLine,
text,
onChange,
onHistoryChange,
}: PierreFileEditorProps) {
const editorRef = useRef<Editor<undefined> | null>(null);
const file = useMemo(() => ({ name: path, contents: text, cacheKey }), [cacheKey, path, text]);
const editorOptions = useMemo<EditorOptions<undefined>>(
() => ({ onChange: (changed) => onChange(changed.contents) }),
[onChange],
() => ({
onAttach: (editor) => {
editorRef.current = editor;
onHistoryChange(editor);
},
onChange: (changed) => {
onChange(changed.contents);
if (editorRef.current) onHistoryChange(editorRef.current);
},
}),
[onChange, onHistoryChange],
);

useEffect(() => () => onHistoryChange(null), [onHistoryChange]);

return (
<EditProvider createEditor={createEditor}>
<File
Expand Down
3 changes: 3 additions & 0 deletions ui/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export const en = {
'work.terminalRelaunchDivider': 'Relaunching terminal',
'work.relaunch': 'Relaunch',
'file.save': 'Save',
'file.undo': 'Undo',
'file.redo': 'Redo',
'file.historyActions': 'Edit history',
'file.discard': 'Discard changes',
'file.saving': 'Saving…',
'file.saved': 'Saved',
Expand Down
18 changes: 18 additions & 0 deletions ui/src/styles/features.css
Original file line number Diff line number Diff line change
Expand Up @@ -1936,6 +1936,24 @@
}
.fv-editor-status.dirty { color: var(--warn); }
.fv-editor-status.error { color: var(--del); }
.fv-editor-history {
display: flex;
align-items: center;
gap: 2px;
padding-right: 10px;
border-right: 0.5px solid var(--border);
}
.fv-editor-history-btn {
width: 28px;
height: 26px;
flex-shrink: 0;
color: var(--text-muted);
}
.fv-editor-history-btn:hover:not(:disabled) {
background: var(--bg-elev);
color: var(--text);
}
.fv-editor-history-btn:disabled { opacity: 0.35; }
.fv-editor-discard {
width: 28px;
height: 26px;
Expand Down
53 changes: 52 additions & 1 deletion ui/src/views/FileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { tokenizeFile, type HlToken, type HlTheme } from '../lib/highlight';
import { useRepo } from '../stores/repo';
import { useSettings } from '../stores/settings';
import { useWork } from '../stores/work';
import type { PierreEditorHistory } from '../components/PierreFileEditor';
import type {
BlameLine,
FileContent,
Expand All @@ -52,6 +53,18 @@ type Tab = 'content' | 'preview' | 'history' | 'compare' | 'blame';
const WORKING = 'working-tree';
const PierreFileEditor = lazy(() => import('../components/PierreFileEditor'));

interface EditorHistoryState {
editor: PierreEditorHistory | null;
canUndo: boolean;
canRedo: boolean;
}

const EMPTY_EDITOR_HISTORY: EditorHistoryState = {
editor: null,
canUndo: false,
canRedo: false,
};

const TABS: { id: Tab; label: string; icon: IconName }[] = [
{ id: 'content', label: 'Content', icon: 'content' },
{ id: 'preview', label: 'Preview', icon: 'eye' },
Expand Down Expand Up @@ -429,6 +442,7 @@ function ContentTab({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editorGeneration, setEditorGeneration] = useState(0);
const [editorHistory, setEditorHistory] = useState<EditorHistoryState>(EMPTY_EDITOR_HISTORY);
const scrollRef = useRef<HTMLDivElement>(null);
const savingRef = useRef(false);
const loadedSourceRef = useRef<string | null>(null);
Expand Down Expand Up @@ -463,6 +477,16 @@ function ContentTab({
);
}, [path, repoPath, setFileDraft]);

const updateEditorHistory = useCallback((editor: PierreEditorHistory | null) => {
const canUndo = editor?.canUndo ?? false;
const canRedo = editor?.canRedo ?? false;
setEditorHistory((current) => (
current.editor === editor && current.canUndo === canUndo && current.canRedo === canRedo
? current
: { editor, canUndo, canRedo }
));
}, []);

// Follow external changes while the document is clean, but never replace a
// user's unsaved buffer. A stale save is rejected by repo_file_write.
useEffect(() => {
Expand Down Expand Up @@ -525,7 +549,10 @@ function ContentTab({
if (cancelled || (refreshingLoadedSource && dirtyRef.current)) return;
loadedSourceRef.current = sourceKey;
setData(c);
if (refreshingLoadedSource) setEditorGeneration((generation) => generation + 1);
if (refreshingLoadedSource) {
setEditorHistory(EMPTY_EDITOR_HISTORY);
setEditorGeneration((generation) => generation + 1);
}
const normalizedDisk = normalizeEditorText(c.text);
const stored = !revision
? useWork.getState().fileDrafts[workFileDraftKey(repoPath, path)]
Expand Down Expand Up @@ -592,6 +619,7 @@ function ContentTab({
// Pierre owns its document after mounting; a new cache key rebuilds it
// from the restored text without touching the working-tree file. Refresh
// afterward so an external disk change that caused a stale save also wins.
setEditorHistory(EMPTY_EDITOR_HISTORY);
setEditorGeneration((generation) => generation + 1);
setDiscardRefreshKey((key) => key + 1);
}, [dirty, path, repoPath, setFileDraft]);
Expand Down Expand Up @@ -650,6 +678,28 @@ function ContentTab({
? t('file.unsaved')
: t('file.saved')}
</span>
<div className="fv-editor-history" role="group" aria-label={t('file.historyActions')}>
<button
type="button"
className="icon-btn fv-editor-history-btn"
disabled={!editorHistory.canUndo}
onClick={() => editorHistory.editor?.undo()}
title={`${t('file.undo')} (Mod+Z)`}
aria-label={t('file.undo')}
>
<Icon name="undo" size={15} />
</button>
<button
type="button"
className="icon-btn fv-editor-history-btn"
disabled={!editorHistory.canRedo}
onClick={() => editorHistory.editor?.redo()}
title={`${t('file.redo')} (Mod+Shift+Z)`}
aria-label={t('file.redo')}
>
<Icon name="redo" size={15} />
</button>
</div>
<button
type="button"
className="icon-btn fv-editor-discard"
Expand Down Expand Up @@ -680,6 +730,7 @@ function ContentTab({
selectedLine={selectedLine}
text={draft}
onChange={updateDraft}
onHistoryChange={updateEditorHistory}
/>
</Suspense>
</div>
Expand Down
5 changes: 4 additions & 1 deletion website/docs/work.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ multiple cursors, smart indentation, bracket matching, and undo/redo. Unsaved
drafts remain available when you switch tabs, panes, views, workspaces, or
repositories during the current app session. Strand does not save on blur,
navigation, or idle time: use the save icon or `Mod+S` when you want to write
the file. Use **Discard changes** beside Save to reset the current unsaved
the file. Use the toolbar's **Undo** and **Redo** controls or `Mod+Z` and
`Mod+Shift+Z`; Windows and Linux also support `Ctrl+Y` for redo. The buttons
disable automatically at the ends of Pierre's structure-aware history. Use
**Discard changes** beside Save to reset the current unsaved
buffer and reload the file from disk without writing it. Historical revisions,
binaries, oversized files, and non-UTF-8 text
stay read-only. If another tool changes the file while you have unsaved edits,
Expand Down
Loading