From ff7009ff8cb27a7894cd7dac011dc954208e0692 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Thu, 6 Aug 2026 13:41:20 +0200 Subject: [PATCH] feat(work): add editor undo redo controls --- README.md | 3 +- ROADMAP.md | 6 +++ TASKS.md | 6 ++- docs/learnings.md | 5 +++ ui/src/components/Icon.tsx | 4 +- ui/src/components/PierreFileEditor.tsx | 27 +++++++++++-- ui/src/lib/i18n.ts | 3 ++ ui/src/styles/features.css | 18 +++++++++ ui/src/views/FileView.tsx | 53 +++++++++++++++++++++++++- website/docs/work.md | 5 ++- 10 files changed, 122 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f58f3f8..3e57fc3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index f691403..f8c5466 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/TASKS.md b/TASKS.md index 9d0af3c..763d7e9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 ``), markdown through `lib/markdown.tsx` diff --git a/docs/learnings.md b/docs/learnings.md index dc4cee2..ab114e0 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -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 diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx index b69b8c0..a0c4361 100644 --- a/ui/src/components/Icon.tsx +++ b/ui/src/components/Icon.tsx @@ -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, 'name' | 'stroke'> { @@ -51,6 +51,8 @@ export function Icon({ name, size = 14, stroke = 1.5, ...rest }: Props) { case 'workspace': return ; case 'bell': return ; case 'save': return ; + case 'undo': return ; + case 'redo': return ; case 'folder-open': return ; case 'changes': return ; case 'search': return ; diff --git a/ui/src/components/PierreFileEditor.tsx b/ui/src/components/PierreFileEditor.tsx index 3b410d2..877b4b7 100644 --- a/ui/src/components/PierreFileEditor.tsx +++ b/ui/src/components/PierreFileEditor.tsx @@ -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'; @@ -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): Editor { @@ -24,13 +32,26 @@ export default function PierreFileEditor({ selectedLine, text, onChange, + onHistoryChange, }: PierreFileEditorProps) { + const editorRef = useRef | null>(null); const file = useMemo(() => ({ name: path, contents: text, cacheKey }), [cacheKey, path, text]); const editorOptions = useMemo>( - () => ({ 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 ( 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' }, @@ -429,6 +442,7 @@ function ContentTab({ const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [editorGeneration, setEditorGeneration] = useState(0); + const [editorHistory, setEditorHistory] = useState(EMPTY_EDITOR_HISTORY); const scrollRef = useRef(null); const savingRef = useRef(false); const loadedSourceRef = useRef(null); @@ -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(() => { @@ -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)] @@ -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]); @@ -650,6 +678,28 @@ function ContentTab({ ? t('file.unsaved') : t('file.saved')} +
+ + +