From f690d12401bab49555e95e853dd84f24b20bfdf7 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Thu, 6 Aug 2026 12:01:09 +0200 Subject: [PATCH] Adopt Pierre edit mode with session drafts --- README.md | 5 +- ROADMAP.md | 9 + TASKS.md | 22 ++- docs/learnings.md | 35 ++-- pnpm-lock.yaml | 63 +++++-- ui/package.json | 2 +- ui/src/components/PierreFileEditor.tsx | 45 +++++ ui/src/lib/fileEditing.test.ts | 38 +++- ui/src/lib/fileEditing.ts | 47 +++++ ui/src/lib/highlight.test.ts | 61 ------- ui/src/lib/highlightProjection.ts | 91 --------- ui/src/lib/i18n.ts | 2 +- ui/src/stores/work.test.ts | 27 +++ ui/src/stores/work.ts | 38 +++- ui/src/styles/features.css | 101 ++-------- ui/src/views/FileView.tsx | 244 +++++++++++-------------- website/docs/work.md | 10 +- 17 files changed, 409 insertions(+), 431 deletions(-) create mode 100644 ui/src/components/PierreFileEditor.tsx delete mode 100644 ui/src/lib/highlight.test.ts delete mode 100644 ui/src/lib/highlightProjection.ts create mode 100644 ui/src/stores/work.test.ts diff --git a/README.md b/README.md index 1265669..f58f3f8 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,10 @@ and the live Diff settings preview. and replaceable preview. New splits match the 50/50 hover preview, while later resizing is remembered for that split. Files retain Content, rendered Preview, History, Compare, Blame, image, and directory - modes. Multiple terminals run at the repository + 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. + 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 grid. Claude Code starts with its complete dashboard and alternate-screen diff --git a/ROADMAP.md b/ROADMAP.md index 1edb9c9..f691403 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2478,6 +2478,15 @@ URLs; and the build emits a sitemap plus robots policy. A repository SEO check fails on duplicate or missing metadata, invalid JSON-LD, broken routes, legacy query/Markdown links, or a sitemap that drifts from the generated pages. +**Pierre file edit mode shipped (2026-08-06):** Work Content documents now +lazy-load `@pierre/diffs/edit` instead of maintaining a separate textarea and +highlight overlay. Unsaved working-tree buffers are retained in session memory +across tab, pane, view, workspace, and repository navigation, follow file moves, +and are cleared only when saved, deleted, or the repository closes. Disk writes +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. + --- ## Cross-cutting tracks (run in parallel with all milestones) diff --git a/TASKS.md b/TASKS.md index 7c560a1..9d0af3c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1208,18 +1208,20 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- - ☑ Tab strip + header (opened via `selectFile` from the Files tab / palette; a Close action returns to Local Changes) - ☑ Content tab — working-tree (or revision) content via `repo_file_content`. - Existing complete UTF-8 working-tree files edit through the syntax-highlighted - `HighlightedEditor` and save through stale-checked `repo_file_write`; revisions, + Existing complete UTF-8 working-tree files edit through Pierre's lazy-loaded + `` surface and save through stale-checked `repo_file_write`; revisions, binaries, oversized files, and non-UTF-8 text stay on Pierre's read-only - ``. Optimistic token projection keeps the last Shiki colors attached - to unchanged text during every edit—there is no plain-text refresh frame—and - reconstructs CRLF token streams without tripping the plain-text fallback. + ``. Session-scoped `useWork.fileDrafts` keeps LF-normalized unsaved text + across tab, pane, view, workspace, and repository navigation and follows + Strand-initiated moves without writing to disk. Writes are explicit only, via the disk save icon or Mod+S; blur and idle time - never save a draft. Focus/watcher refreshes keep the mounted editor and token - map in place, and a refresh that finishes after typing starts cannot replace - the draft (`ContentTab` loaded-source/dirty guards). - Mod+F searches the source with wrap-around match navigation and - virtualized-line scrolling (`FileSearchBar` + `searchFileText`). + never save a draft. The adjacent Discard changes action clears only the + in-memory draft and reloads the working-tree file without writing it. + Focus/watcher refreshes keep the mounted editor in place, + 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. - ☑ 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 bb911ad..dc4cee2 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1923,23 +1923,24 @@ session, and transform/remove those markers on move/delete. Explicit directory paths must stay out of PierreTree's file set so selection and context menus keep classifying them as folders. -**In-app text writes are optimistic and encoding-preserving (2026-07-19).** A -file editor must send the exact content it last read and the core must reject a -write when the disk copy no longer matches; agents and external editors share -the working tree, so last-writer-wins would silently destroy work. Mutate only -complete UTF-8 regular files behind `safe_workdir_path`, reject symlinks and -oversized/binary content, and preserve a consistently-CRLF file's line endings -after textarea normalization. Commit/revision content remains immutable. -Keep the last valid token map projected onto the current buffer while an async -syntax refresh is pending; never replace a highlighted editor with plain text -merely because its token result is one input behind. The same rule applies to -focus/watcher refetches: keep the loaded editor mounted while the read is in -flight, then update its buffer in place so the existing token projection -survives. Reserve the empty loading surface for the initial file/source load. -Web textareas and Shiki expose LF line boundaries even when a Windows checkout -is CRLF. Normalize the editable in-memory buffer to LF, keep the raw last-read -text separately for optimistic writes, and reconstruct token streams with the -source's actual separators whenever exact-source validation is required. +**In-app text writes are optimistic and encoding-preserving (2026-07-19, +updated 2026-08-06).** A file editor must send the exact content it last read +and the core must reject a write when the disk copy no longer matches; agents +and external editors share the working tree, so last-writer-wins would silently +destroy work. Mutate only complete UTF-8 regular files behind +`safe_workdir_path`, reject symlinks and oversized/binary content, and preserve +a consistently-CRLF file's line endings after editor normalization. +Commit/revision content remains immutable. Pierre's lazy-loaded `` +surface owns live tokenization and editor behavior; do not restore the parallel +textarea/highlight overlay. Focus/watcher refetches keep the loaded editor +mounted while the read is in flight, and a completed refresh must never replace +an unsaved draft. Reserve the empty loading surface for the initial file/source +load. Pierre's editable document exposes LF line boundaries even when a Windows +checkout is CRLF. Normalize the session buffer to LF and keep the raw last-read +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. **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/pnpm-lock.yaml b/pnpm-lock.yaml index 571f0e7..dd7210e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: ui: dependencies: '@pierre/diffs': - specifier: ^1.2.3 - version: 1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^1.3.4 + version: 1.3.4(@shikijs/themes@3.23.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@pierre/theme': specifier: 1.1.0 version: 1.1.0 @@ -365,20 +365,20 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} - '@pierre/diffs@1.2.3': - resolution: {integrity: sha512-ul83DHH1yqgGxJAw2tqQm2gDO+oQsaF82ZVocwJYfXAm2FhZyyKPTdtv6jswR4A5eF/ILPjiQxyfScMhQcofbA==} + '@pierre/diffs@1.3.4': + resolution: {integrity: sha512-6Dt48jQIL+H54QyNB943Oqn5p4uRw7NpiXLSEapnaYgYvTmSHNPAUCDfg6AcRC+W02z0IYJnJJvcs3lsNp7gCw==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 - '@pierre/theme@1.0.3': - resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==} - engines: {vscode: ^1.0.0} - '@pierre/theme@1.1.0': resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} engines: {vscode: ^1.0.0} + '@pierre/theme@2.0.0': + resolution: {integrity: sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==} + engines: {vscode: ^1.0.0} + '@pierre/theming@0.0.2': resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} peerDependencies: @@ -399,6 +399,26 @@ packages: shiki: optional: true + '@pierre/theming@1.0.1': + resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==} + peerDependencies: + '@pierre/theme': ^1.1.0 || ^2.0.0 + '@shikijs/themes': ^3.0.0 || ^4.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + shiki: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + '@shikijs/themes': + optional: true + react: + optional: true + react-dom: + optional: true + shiki: + optional: true + '@pierre/trees@1.0.0-beta.5': resolution: {integrity: sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA==} peerDependencies: @@ -1190,8 +1210,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} dompurify@3.4.11: @@ -2050,21 +2070,24 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@pierre/diffs@1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@pierre/diffs@1.3.4(@shikijs/themes@3.23.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@pierre/theme': 1.0.3 + '@pierre/theme': 2.0.0 + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@3.23.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(shiki@3.23.0) '@shikijs/transformers': 3.23.0 - diff: 8.0.3 + diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) shiki: 3.23.0 - - '@pierre/theme@1.0.3': {} + transitivePeerDependencies: + - '@shikijs/themes' '@pierre/theme@1.1.0': {} + '@pierre/theme@2.0.0': {} + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@3.23.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(shiki@3.23.0)': optionalDependencies: '@pierre/theme': 1.1.0 @@ -2073,6 +2096,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) shiki: 3.23.0 + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@3.23.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(shiki@3.23.0)': + optionalDependencies: + '@pierre/theme': 2.0.0 + '@shikijs/themes': 3.23.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + shiki: 3.23.0 + '@pierre/trees@1.0.0-beta.5(patch_hash=dqeagyv5lxw2fsmlmzbl6ueoay)(@pierre/theme@1.1.0)(@shikijs/themes@3.23.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(shiki@3.23.0)': dependencies: '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@3.23.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(shiki@3.23.0) @@ -2868,7 +2899,7 @@ snapshots: dependencies: dequal: 2.0.3 - diff@8.0.3: {} + diff@9.0.0: {} dompurify@3.4.11: optionalDependencies: diff --git a/ui/package.json b/ui/package.json index 284c188..0306c43 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,7 +15,7 @@ "doctor": "npx react-doctor@latest" }, "dependencies": { - "@pierre/diffs": "^1.2.3", + "@pierre/diffs": "^1.3.4", "@pierre/theme": "1.1.0", "@pierre/trees": "1.0.0-beta.5", "@tauri-apps/api": "^2", diff --git a/ui/src/components/PierreFileEditor.tsx b/ui/src/components/PierreFileEditor.tsx new file mode 100644 index 0000000..3b410d2 --- /dev/null +++ b/ui/src/components/PierreFileEditor.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import type { FileOptions } from '@pierre/diffs/react'; +import { EditProvider, File } from '@pierre/diffs/react'; +import { Editor, type EditorOptions } from '@pierre/diffs/edit'; + +interface PierreFileEditorProps { + cacheKey: string; + options: FileOptions; + path: string; + selectedLine: number | null; + text: string; + onChange(text: string): void; +} + +function createEditor(options: EditorOptions): Editor { + return new Editor(options); +} + +/** Lazy-loaded boundary around Pierre's experimental edit entry point. */ +export default function PierreFileEditor({ + cacheKey, + options, + path, + selectedLine, + text, + onChange, +}: PierreFileEditorProps) { + const file = useMemo(() => ({ name: path, contents: text, cacheKey }), [cacheKey, path, text]); + const editorOptions = useMemo>( + () => ({ onChange: (changed) => onChange(changed.contents) }), + [onChange], + ); + + return ( + + + + ); +} diff --git a/ui/src/lib/fileEditing.test.ts b/ui/src/lib/fileEditing.test.ts index 153c792..79eebda 100644 --- a/ui/src/lib/fileEditing.test.ts +++ b/ui/src/lib/fileEditing.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { canEditFileContent } from './fileEditing'; +import { + canEditFileContent, + normalizeEditorText, + reconcileWorkFileDrafts, + workFileDraftKey, + type WorkFileDraft, +} from './fileEditing'; describe('file content editing', () => { it('keeps accepted working-tree text editable in every presentation', () => { @@ -11,4 +17,34 @@ describe('file content editing', () => { expect(canEditFileContent(true, 'abc123')).toBe(false); expect(canEditFileContent(false, null)).toBe(false); }); + + it('normalizes editable buffers without changing their optimistic-write base', () => { + expect(normalizeEditorText('one\r\ntwo\rthree')).toBe('one\ntwo\nthree'); + }); + + it('keeps drafts repository-scoped and follows file mutations', () => { + const repo = 'C:\\code\\strand'; + const otherRepo = 'C:\\code\\other'; + const draft: WorkFileDraft = { original: 'before\r\n', text: 'after\n' }; + const otherDraft: WorkFileDraft = { original: 'x', text: 'y' }; + const drafts = { + [workFileDraftKey(repo, 'src/app.ts')]: draft, + [workFileDraftKey(otherRepo, 'src/app.ts')]: otherDraft, + }; + + const moved = reconcileWorkFileDrafts(drafts, repo, { + kind: 'move', + moves: [{ from: 'src', to: 'client' }], + }); + expect(moved[workFileDraftKey(repo, 'src/app.ts')]).toBeUndefined(); + expect(moved[workFileDraftKey(repo, 'client/app.ts')]).toBe(draft); + expect(moved[workFileDraftKey(otherRepo, 'src/app.ts')]).toBe(otherDraft); + + const removed = reconcileWorkFileDrafts(moved, repo, { + kind: 'delete', + paths: ['client'], + }); + expect(removed[workFileDraftKey(repo, 'client/app.ts')]).toBeUndefined(); + expect(removed[workFileDraftKey(otherRepo, 'src/app.ts')]).toBe(otherDraft); + }); }); diff --git a/ui/src/lib/fileEditing.ts b/ui/src/lib/fileEditing.ts index 0e6d5a3..189a470 100644 --- a/ui/src/lib/fileEditing.ts +++ b/ui/src/lib/fileEditing.ts @@ -1,3 +1,50 @@ +import type { FilesTreeMutationChange } from './types'; + +export interface WorkFileDraft { + /** Raw disk contents used by the optimistic write guard. */ + original: string; + /** LF-normalized text currently shown by the editor. */ + text: string; +} + +export function normalizeEditorText(text: string): string { + return text.replace(/\r\n?/g, '\n'); +} + +export function workFileDraftKey(repoPath: string, path: string): string { + return `${repoPath}\0${path}`; +} + +/** Keep session drafts aligned with file moves and deletes initiated in Strand. */ +export function reconcileWorkFileDrafts( + drafts: Record, + repoPath: string, + change: FilesTreeMutationChange, +): Record { + if (change.kind !== 'move' && change.kind !== 'delete') return drafts; + const prefix = `${repoPath}\0`; + let next = drafts; + + for (const [key, draft] of Object.entries(drafts)) { + if (!key.startsWith(prefix)) continue; + const path = key.slice(prefix.length); + if (change.kind === 'delete') { + if (!change.paths.some((removed) => path === removed || path.startsWith(`${removed}/`))) continue; + if (next === drafts) next = { ...drafts }; + delete next[key]; + continue; + } + + const move = change.moves.find(({ from }) => path === from || path.startsWith(`${from}/`)); + if (!move) continue; + if (next === drafts) next = { ...drafts }; + delete next[key]; + next[workFileDraftKey(repoPath, `${move.to}${path.slice(move.from.length)}`)] = draft; + } + + return next; +} + /** Working-tree text can be edited only when the backend accepted the file. * Presentation context (standalone or embedded in Work) is intentionally not * part of this decision. */ diff --git a/ui/src/lib/highlight.test.ts b/ui/src/lib/highlight.test.ts deleted file mode 100644 index b989455..0000000 --- a/ui/src/lib/highlight.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { HlToken } from './highlight'; -import { projectTokenColors } from './highlightProjection'; - -const tokens: HlToken[][] = [ - [ - { content: 'const', color: '#keyword' }, - { content: ' value', color: '#name' }, - ], - [ - { content: 'return', color: '#keyword' }, - { content: ' value', color: '#name' }, - ], -]; - -function text(segments: HlToken[]): string { - return segments.map((segment) => segment.content).join(''); -} - -function colorOf(segments: HlToken[], needle: string): string | undefined { - return segments.find((segment) => segment.content.includes(needle))?.color; -} - -describe('projectTokenColors', () => { - it('keeps syntax colors across an inserted line while using the current text', () => { - const projected = projectTokenColors( - 'const value\nreturn value', - 'const value\n\nreturn value', - tokens, - ); - - expect(text(projected)).toBe('const value\n\nreturn value'); - expect(colorOf(projected, 'const')).toBe('#keyword'); - expect(colorOf(projected, 'return')).toBe('#keyword'); - }); - - it('borrows the replaced token color until retokenization finishes', () => { - const projected = projectTokenColors( - 'const value\nreturn value', - 'let value\nreturn value', - tokens, - ); - - expect(text(projected)).toBe('let value\nreturn value'); - expect(colorOf(projected, 'let')).toBe('#keyword'); - expect(colorOf(projected, 'return')).toBe('#keyword'); - }); - - it('reconstructs CRLF buffers without discarding their token colors', () => { - const projected = projectTokenColors( - 'const value\r\nreturn value', - 'const value\r\n\r\nreturn value', - tokens, - ); - - expect(text(projected)).toBe('const value\r\n\r\nreturn value'); - expect(colorOf(projected, 'const')).toBe('#keyword'); - expect(colorOf(projected, 'return')).toBe('#keyword'); - }); -}); diff --git a/ui/src/lib/highlightProjection.ts b/ui/src/lib/highlightProjection.ts deleted file mode 100644 index 4200930..0000000 --- a/ui/src/lib/highlightProjection.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { HlToken } from './highlight'; - -function mergeSegments(segments: HlToken[]): HlToken[] { - const merged: HlToken[] = []; - for (const segment of segments) { - if (!segment.content) continue; - const previous = merged.at(-1); - if (previous && previous.color === segment.color) previous.content += segment.content; - else merged.push({ ...segment }); - } - return merged; -} - -function flattenTokens(tokens: HlToken[][], source: string): HlToken[] { - const segments: HlToken[] = []; - const lineBreaks = source.match(/\r\n|\r|\n/g) ?? []; - tokens.forEach((line, lineIndex) => { - segments.push(...line); - if (lineIndex < tokens.length - 1) { - segments.push({ content: lineBreaks[lineIndex] ?? '\n' }); - } - }); - return mergeSegments(segments); -} - -function sliceSegments(segments: HlToken[], start: number, end: number): HlToken[] { - if (start >= end) return []; - const sliced: HlToken[] = []; - let offset = 0; - for (const segment of segments) { - const segmentEnd = offset + segment.content.length; - if (segmentEnd > start && offset < end) { - sliced.push({ - content: segment.content.slice(Math.max(0, start - offset), Math.min(segment.content.length, end - offset)), - color: segment.color, - }); - } - offset = segmentEnd; - if (offset >= end) break; - } - return sliced; -} - -function colorAt(segments: HlToken[], offset: number): string | undefined { - let cursor = 0; - for (const segment of segments) { - cursor += segment.content.length; - if (offset < cursor) return segment.color; - } - return segments.at(-1)?.color; -} - -/** - * Keep the last Shiki colors attached to unchanged text while a newly edited - * buffer is being tokenized. The changed slice borrows its nearest token color - * until the authoritative token pass arrives. This guarantees that typing - * never swaps the whole editor to a plain-text frame. - */ -export function projectTokenColors( - source: string, - target: string, - tokens: HlToken[][], -): HlToken[] { - const segments = flattenTokens(tokens, source); - if (segments.map((segment) => segment.content).join('') !== source) { - return [{ content: target }]; - } - if (source === target) return segments; - - let prefix = 0; - const sharedLength = Math.min(source.length, target.length); - while (prefix < sharedLength && source.charCodeAt(prefix) === target.charCodeAt(prefix)) prefix++; - - let suffix = 0; - while ( - suffix < source.length - prefix - && suffix < target.length - prefix - && source.charCodeAt(source.length - suffix - 1) === target.charCodeAt(target.length - suffix - 1) - ) suffix++; - - const changed = target.slice(prefix, target.length - suffix); - const changedColor = colorAt( - segments, - Math.min(prefix, Math.max(0, source.length - 1)), - ); - return mergeSegments([ - ...sliceSegments(segments, 0, prefix), - { content: changed, color: changedColor }, - ...sliceSegments(segments, source.length - suffix, source.length), - ]); -} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index bdb1f93..9102662 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -61,8 +61,8 @@ export const en = { 'work.terminalUnknownError': 'Unknown error', 'work.terminalRelaunchDivider': 'Relaunching terminal', 'work.relaunch': 'Relaunch', - 'file.editorLabel': 'Edit {path}', 'file.save': 'Save', + 'file.discard': 'Discard changes', 'file.saving': 'Saving…', 'file.saved': 'Saved', 'file.unsaved': 'Unsaved changes', diff --git a/ui/src/stores/work.test.ts b/ui/src/stores/work.test.ts new file mode 100644 index 0000000..d44edde --- /dev/null +++ b/ui/src/stores/work.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/db', () => ({ + settings: { get: vi.fn(), set: vi.fn() }, +})); +vi.mock('./settings', () => ({ + useSettings: { getState: () => ({ fileOpenTab: 'content' }) }, +})); + +import { workFileDraftKey } from '../lib/fileEditing'; +import { useWork } from './work'; + +afterEach(() => useWork.setState({ fileDrafts: {} })); + +describe('Work file drafts', () => { + it('drops a session draft without changing other files', () => { + const repo = 'C:\\code\\strand'; + const draft = { original: 'before', text: 'after' }; + useWork.getState().setFileDraft(repo, 'one.ts', draft); + useWork.getState().setFileDraft(repo, 'two.ts', draft); + + useWork.getState().setFileDraft(repo, 'one.ts', null); + + expect(useWork.getState().fileDrafts[workFileDraftKey(repo, 'one.ts')]).toBeUndefined(); + expect(useWork.getState().fileDrafts[workFileDraftKey(repo, 'two.ts')]).toEqual(draft); + }); +}); diff --git a/ui/src/stores/work.ts b/ui/src/stores/work.ts index b44c3ca..f9b58e1 100644 --- a/ui/src/stores/work.ts +++ b/ui/src/stores/work.ts @@ -30,6 +30,11 @@ import { import type { FilesTreeMutationChange } from '../lib/types'; import type { EmbeddedShellChoice } from '../lib/types'; import { isPreviewablePath } from '../lib/preview'; +import { + reconcileWorkFileDrafts, + workFileDraftKey, + type WorkFileDraft, +} from '../lib/fileEditing'; import { useSettings } from './settings'; const key = (repoPath: string) => `work-terminals:${repoPath}`; @@ -38,6 +43,8 @@ const makeId = () => crypto.randomUUID(); interface WorkState { repos: Record; + /** Unsaved working-tree buffers live for this app session only. */ + fileDrafts: Record; restore(repoPath: string): Promise; openFile( repoPath: string, @@ -68,6 +75,7 @@ interface WorkState { ): void; clearTerminalRuntime(repoPath: string, id: string): void; reconcile(repoPath: string, change: FilesTreeMutationChange): void; + setFileDraft(repoPath: string, path: string, draft: WorkFileDraft | null): void; clearRepo(repoPath: string): Promise; } @@ -78,6 +86,7 @@ function persistTerminals(repoPath: string, state: RepoWorkTabs): void { export const useWork = create((set, get) => ({ repos: {}, + fileDrafts: {}, async restore(repoPath) { if (get().repos[repoPath]?.restored) return; @@ -296,8 +305,27 @@ export const useWork = create((set, get) => ({ reconcile(repoPath, change) { set((state) => { const repo = state.repos[repoPath]; - if (!repo) return state; - return { repos: { ...state.repos, [repoPath]: reconcileWorkMutation(repo, change) } }; + const fileDrafts = reconcileWorkFileDrafts(state.fileDrafts, repoPath, change); + if (!repo) return fileDrafts === state.fileDrafts ? state : { fileDrafts }; + return { + repos: { ...state.repos, [repoPath]: reconcileWorkMutation(repo, change) }, + fileDrafts, + }; + }); + }, + + setFileDraft(repoPath, path, draft) { + const draftKey = workFileDraftKey(repoPath, path); + set((state) => { + const current = state.fileDrafts[draftKey]; + if (draft == null) { + if (current == null) return state; + const fileDrafts = { ...state.fileDrafts }; + delete fileDrafts[draftKey]; + return { fileDrafts }; + } + if (current?.original === draft.original && current.text === draft.text) return state; + return { fileDrafts: { ...state.fileDrafts, [draftKey]: draft } }; }); }, @@ -312,7 +340,11 @@ export const useWork = create((set, get) => ({ set((state) => { const repos = { ...state.repos }; delete repos[repoPath]; - return { repos }; + const prefix = `${repoPath}\0`; + const fileDrafts = Object.fromEntries( + Object.entries(state.fileDrafts).filter(([draftKey]) => !draftKey.startsWith(prefix)), + ); + return { repos, fileDrafts }; }); await settings.set(key(repoPath), []); }, diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 9b1c50b..f80f806 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -1936,6 +1936,17 @@ } .fv-editor-status.dirty { color: var(--warn); } .fv-editor-status.error { color: var(--del); } +.fv-editor-discard { + width: 28px; + height: 26px; + flex-shrink: 0; + color: var(--text-muted); +} +.fv-editor-discard:hover:not(:disabled) { + background: var(--del-bg); + color: var(--del); +} +.fv-editor-discard:disabled { opacity: 0.35; } .fv-editor-save { width: 28px; height: 26px; @@ -1953,97 +1964,9 @@ background: var(--bg-elev); color: var(--text-dim); } -.fv-editor { - flex: 1; - min-height: 0; - display: flex; - overflow: hidden; - background: var(--bg-base); +.fv-pierre-edit { font-family: var(--font-mono); font-size: var(--type-mono); - line-height: 18px; -} -.fv-editor-gutter { - position: relative; - z-index: 3; - width: 52px; - flex-shrink: 0; - overflow: hidden; - border-right: 0.5px solid var(--border); - background: var(--gutter); - color: var(--text-dim); - text-align: right; - user-select: none; -} -.fv-editor-gutter pre, -.fv-editor-highlight { - box-sizing: border-box; - margin: 0; - font: inherit; - line-height: inherit; - tab-size: 4; - white-space: pre; -} -.fv-editor-gutter pre { - width: 100%; - padding: 10px 10px 10px 4px; -} -.fv-editor-surface { - position: relative; - flex: 1; - min-width: 0; - min-height: 0; - overflow: hidden; -} -.fv-editor-highlight { - position: absolute; - z-index: 1; - top: 0; - left: 0; - min-width: 100%; - min-height: 100%; - padding: 10px 16px; - color: var(--text); - pointer-events: none; - transform-origin: top left; -} -.fv-editor-selected-line { - position: absolute; - z-index: 0; - left: 0; - right: 0; - height: 18px; - background: var(--bg-sel); - pointer-events: none; -} -.fv-editor-input { - position: absolute; - z-index: 2; - inset: 0; - box-sizing: border-box; - width: 100%; - height: 100%; - resize: none; - overflow: auto; - border: 0; - outline: none; - padding: 10px 16px; - background: transparent; - color: transparent; - caret-color: var(--text); - font: inherit; - line-height: inherit; - tab-size: 4; - white-space: pre; - -webkit-text-fill-color: transparent; -} - -.fv-editor-input::-webkit-scrollbar-corner { - background: var(--bg-base); -} - -.fv-editor-input::selection { - background: var(--selection); } .fv-empty { margin: auto; diff --git a/ui/src/views/FileView.tsx b/ui/src/views/FileView.tsx index bfd63d3..78e59cb 100644 --- a/ui/src/views/FileView.tsx +++ b/ui/src/views/FileView.tsx @@ -1,4 +1,6 @@ import { + lazy, + Suspense, useCallback, useEffect, useId, @@ -18,16 +20,20 @@ import { Select } from '../components/Select'; import { TreeFileIcon, TreeIconSprite } from '../components/TreeFileIcon'; import { imageMime, isImagePath } from '../lib/image'; import { directoryEntries, type DirectoryEntry } from '../lib/directoryEntries'; -import { canEditFileContent } from '../lib/fileEditing'; +import { + canEditFileContent, + normalizeEditorText, + workFileDraftKey, +} from '../lib/fileEditing'; import { t } from '../lib/i18n'; import { renderMarkdown } from '../lib/markdown'; import { isPreviewablePath, isSvgPath } from '../lib/preview'; import { repoFamilyName } from '../lib/repoIdentity'; import { errMessage, tauri } from '../lib/tauri'; import { tokenizeFile, type HlToken, type HlTheme } from '../lib/highlight'; -import { projectTokenColors } from '../lib/highlightProjection'; import { useRepo } from '../stores/repo'; import { useSettings } from '../stores/settings'; +import { useWork } from '../stores/work'; import type { BlameLine, FileContent, @@ -44,10 +50,7 @@ type Tab = 'content' | 'preview' | 'history' | 'compare' | 'blame'; * Any non-hex string works — it never reaches git (the working-tree branch * calls `repoDiffWorkdirFile`), it only needs to differ from real OIDs. */ const WORKING = 'working-tree'; - -function normalizeEditorText(text: string): string { - return text.replace(/\r\n?/g, '\n'); -} +const PierreFileEditor = lazy(() => import('../components/PierreFileEditor')); const TABS: { id: Tab; label: string; icon: IconName }[] = [ { id: 'content', label: 'Content', icon: 'content' }, @@ -165,6 +168,9 @@ export function FileDocument({ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.key.toLowerCase() !== 'f') return; const target = e.target as HTMLElement | null; if (target?.closest('[role="dialog"], [role="combobox"], .palette-backdrop')) return; + // Pierre edit mode owns its richer find/replace surface. Strand's search + // remains the read-only and command-palette fallback. + if (target?.closest('.fv-pierre-edit')) return; e.preventDefault(); openSearch(); }; @@ -413,6 +419,7 @@ function ContentTab({ const pierreTheme: HlTheme = useSettings((s) => s.resolvedTheme) === 'light' ? 'pierre-light' : 'pierre-dark'; const diffsTick = useRepo((s) => s.diffsTick); + const setFileDraft = useWork((s) => s.setFileDraft); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -421,17 +428,40 @@ function ContentTab({ const [original, setOriginal] = useState(''); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [editorGeneration, setEditorGeneration] = useState(0); const scrollRef = useRef(null); const savingRef = useRef(false); const loadedSourceRef = useRef(null); + const draftRef = useRef(draft); + const originalRef = useRef(original); const selectLine = useCallback((line: number | null) => setSelectedLine(line), []); const editable = canEditFileContent(Boolean(data?.editable), revision); const normalizedOriginal = useMemo(() => normalizeEditorText(original), [original]); const dirty = editable && draft !== normalizedOriginal; const dirtyRef = useRef(dirty); dirtyRef.current = dirty; + draftRef.current = draft; + originalRef.current = original; const [refetchKey, setRefetchKey] = useState(diffsTick); + const [discardRefreshKey, setDiscardRefreshKey] = useState(0); const sourceKey = `${repoPath ?? ''}\0${path}\0${revision ?? ''}`; + const pierreOptions = useMemo( + () => ({ theme: pierreTheme, disableBackground: true, disableFileHeader: true }), + [pierreTheme], + ); + + const updateDraft = useCallback((next: string) => { + draftRef.current = next; + setDraft(next); + setSaveError(null); + if (!repoPath) return; + const base = originalRef.current; + setFileDraft( + repoPath, + path, + next === normalizeEditorText(base) ? null : { original: base, text: next }, + ); + }, [path, repoPath, setFileDraft]); // 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. @@ -495,8 +525,23 @@ function ContentTab({ if (cancelled || (refreshingLoadedSource && dirtyRef.current)) return; loadedSourceRef.current = sourceKey; setData(c); - setDraft(normalizeEditorText(c.text)); - setOriginal(c.text); + if (refreshingLoadedSource) setEditorGeneration((generation) => generation + 1); + const normalizedDisk = normalizeEditorText(c.text); + const stored = !revision + ? useWork.getState().fileDrafts[workFileDraftKey(repoPath, path)] + : undefined; + if (stored && stored.text !== normalizedDisk) { + originalRef.current = stored.original; + draftRef.current = stored.text; + setOriginal(stored.original); + setDraft(stored.text); + } else { + originalRef.current = c.text; + draftRef.current = normalizedDisk; + setOriginal(c.text); + setDraft(normalizedDisk); + if (stored) setFileDraft(repoPath, path, null); + } }) .catch((e) => { if (cancelled) return; @@ -505,27 +550,51 @@ function ContentTab({ }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [repoPath, path, revision, refetchKey, sourceKey]); + }, [repoPath, path, revision, refetchKey, discardRefreshKey, sourceKey, setFileDraft]); const save = useCallback(async () => { if (!repoPath || !data?.editable || revision || !dirty || savingRef.current) return; - const next = draft; + const next = draftRef.current; + const base = originalRef.current; savingRef.current = true; setSaving(true); setSaveError(null); try { - const saved = await tauri.repoFileWrite(repoPath, path, original, next); + const saved = await tauri.repoFileWrite(repoPath, path, base, next); setData(saved); + originalRef.current = saved.text; setOriginal(saved.text); // Do not clobber keystrokes entered while the write was in flight. - setDraft((current) => current === next ? normalizeEditorText(saved.text) : current); + const current = draftRef.current; + if (current === next) { + const normalizedSaved = normalizeEditorText(saved.text); + draftRef.current = normalizedSaved; + setDraft(normalizedSaved); + setFileDraft(repoPath, path, null); + } else { + setFileDraft(repoPath, path, { original: saved.text, text: current }); + } } catch (caught) { setSaveError(errMessage(caught)); } finally { savingRef.current = false; setSaving(false); } - }, [data?.editable, dirty, draft, original, path, repoPath, revision]); + }, [data?.editable, dirty, path, repoPath, revision, setFileDraft]); + + const discardDraft = useCallback(() => { + if (!repoPath || !dirty || savingRef.current) return; + const restored = normalizeEditorText(originalRef.current); + draftRef.current = restored; + setDraft(restored); + setSaveError(null); + setFileDraft(repoPath, path, null); + // 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. + setEditorGeneration((generation) => generation + 1); + setDiscardRefreshKey((key) => key + 1); + }, [dirty, path, repoPath, setFileDraft]); useEffect(() => { if (!editable) return; @@ -553,10 +622,7 @@ function ContentTab({ ); } - // `disableBackground` keeps Pierre's surface on our tokens (honored at - // runtime; not in `FileOptions`' type, so we build the object outside the - // JSX to skip the excess-property check — same as MergeResolver). - const opts = { theme: pierreTheme, disableBackground: true, disableFileHeader: true }; + // `disableBackground` keeps Pierre's read and edit surfaces on our tokens. const searchText = editable ? draft : data.text; return (
@@ -584,6 +650,16 @@ function ContentTab({ ? t('file.unsaved') : t('file.saved')} +
- { - setDraft(next); - setSaveError(null); - }} - /> +
+ + + +
) : (
@@ -619,116 +697,6 @@ function ContentTab({ ); } -const EDITOR_LINE_HEIGHT = 18; - -function HighlightedEditor({ - path, - text, - theme, - selectedLine, - onChange, -}: { - path: string; - text: string; - theme: HlTheme; - selectedLine: number | null; - onChange: (text: string) => void; -}) { - const inputRef = useRef(null); - const [scrollTop, setScrollTop] = useState(0); - const [scrollLeft, setScrollLeft] = useState(0); - const [highlight, setHighlight] = useState<{ text: string; tokens: HlToken[][] } | null>(null); - const lineCount = useMemo(() => text.split('\n').length, [text]); - const gutter = useMemo( - () => Array.from({ length: lineCount }, (_, index) => index + 1).join('\n'), - [lineCount], - ); - - useEffect(() => { - let cancelled = false; - const timer = window.setTimeout(() => { - void tokenizeFile(text, path, theme).then((tokens) => { - if (!cancelled && tokens) setHighlight({ text, tokens }); - }); - }, 24); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [path, text, theme]); - - useEffect(() => { - if (selectedLine == null) return; - const input = inputRef.current; - if (!input) return; - const target = (selectedLine - 0.5) * EDITOR_LINE_HEIGHT - input.clientHeight / 2; - input.scrollTo({ top: Math.max(0, target) }); - setScrollTop(input.scrollTop); - }, [selectedLine]); - - const highlighted = useMemo(() => { - if (!highlight) return text; - return projectTokenColors(highlight.text, text, highlight.tokens).map((token, index) => ( - - {token.content} - - )); - }, [highlight, text]); - - const insertTab = useCallback(() => { - const input = inputRef.current; - if (!input) return; - const start = input.selectionStart; - const end = input.selectionEnd; - onChange(`${text.slice(0, start)}\t${text.slice(end)}`); - requestAnimationFrame(() => input.setSelectionRange(start + 1, start + 1)); - }, [onChange, text]); - - return ( -
-
-
{gutter}
-
-
- {selectedLine != null && ( -
- )} -
-          {highlighted}
-        
-