diff --git a/README.md b/README.md index 1265669..db6e09f 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,8 @@ and the live Diff settings preview. - **Command palette (⌘K)** — fuzzy search across commands, branches, tags, files, commits, and recent repos, with scope filtering and full keyboard + screen-reader operability. -- **Work file documents** — edit syntax-highlighted working-tree files or - inspect historical source read-only; `--follow` history, +- **Work file documents** — edit working-tree files with Pierre Diffs edit mode + (unsaved drafts persist while navigating) or inspect historical source read-only; `--follow` history, compare any two revisions, blame, and rendered previews for markdown and SVG; the Files tree uses the local filesystem listing directly, including muted Git-ignored paths, while overlaying current Git-state colors and recognizable language diff --git a/ROADMAP.md b/ROADMAP.md index 1edb9c9..e82f5c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2220,14 +2220,12 @@ loadable while `.git` stays inaccessible; the snapshot/status hot path remains unchanged. **Syntax-highlighted file editing shipped (2026-07-19):** Opening an existing -UTF-8 working-tree file from Files now mounts a lightweight editor backed by -Pierre's shared Shiki tokenizer. Save and Mod+S explicitly write through +UTF-8 working-tree file from Files now mounts Pierre Diffs edit mode. Save and Mod+S explicitly write through `repo_file_write`; the core preserves consistent CRLF endings and rejects stale, traversal, symlink, binary, non-UTF-8, and oversized writes. -Historical revisions remain read-only. The editor projects the last Shiki token -colors onto each new buffer immediately, so background retokenization never -causes a plain-text flash while typing. Its in-memory buffer is LF-normalized -for textarea/Shiki agreement while the core retains and restores the exact CRLF +Historical revisions remain read-only. Unsaved working-tree drafts now stay in +memory when users switch files, tabs, repositories, or Work panes, but writes +remain explicit-only; the core retains and restores the exact CRLF disk form on save. **Files creation toolbar refined (2026-07-19):** The separate New file and New diff --git a/TASKS.md b/TASKS.md index 7c560a1..0744ba2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1208,16 +1208,14 @@ 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, - 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. + Existing complete UTF-8 working-tree files edit through Pierre Diffs edit mode + (`EditProvider`/`Editor`) and save through stale-checked `repo_file_write`; + revisions, binaries, oversized files, and non-UTF-8 text stay on Pierre's + read-only ``. 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). + never save a draft. Unsaved drafts stay in memory when navigating around + Strand. Focus/watcher refreshes keep the mounted editor state 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`). - ☑ Preview tab — rendered view for renderable text files, tab only offered diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 571f0e7..9a9a037 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.3 + version: 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@pierre/theme': specifier: 1.1.0 version: 1.1.0 @@ -365,8 +365,8 @@ 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.3': + resolution: {tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.3.3.tgz} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -379,6 +379,9 @@ packages: resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} engines: {vscode: ^1.0.0} + '@pierre/theme@2.0.0': + resolution: {tarball: https://registry.npmjs.org/@pierre/theme/-/theme-2.0.0.tgz} + '@pierre/theming@0.0.2': resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} peerDependencies: @@ -399,6 +402,20 @@ packages: shiki: optional: true + '@pierre/theming@1.0.0': + resolution: {tarball: https://registry.npmjs.org/@pierre/theming/-/theming-1.0.0.tgz} + peerDependencies: + '@pierre/theme': ^2.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + react: + optional: true + react-dom: + optional: true + '@pierre/trees@1.0.0-beta.5': resolution: {integrity: sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA==} peerDependencies: @@ -1190,8 +1207,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: {tarball: https://registry.npmjs.org/diff/-/diff-9.0.0.tgz} engines: {node: '>=0.3.1'} dompurify@3.4.11: @@ -2050,21 +2067,29 @@ 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.3(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.0(@pierre/theme@2.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@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': {} '@pierre/theme@1.1.0': {} + '@pierre/theme@2.0.0': {} + + '@pierre/theming@1.0.0(@pierre/theme@2.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@pierre/theme': 2.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@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 @@ -2868,7 +2893,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..ce9b71e 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.3", "@pierre/theme": "1.1.0", "@pierre/trees": "1.0.0-beta.5", "@tauri-apps/api": "^2", diff --git a/ui/src/lib/fileEditing.test.ts b/ui/src/lib/fileEditing.test.ts index 153c792..9790e93 100644 --- a/ui/src/lib/fileEditing.test.ts +++ b/ui/src/lib/fileEditing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { canEditFileContent } from './fileEditing'; +import { canEditFileContent, fileDraftKey } from './fileEditing'; describe('file content editing', () => { it('keeps accepted working-tree text editable in every presentation', () => { @@ -11,4 +11,10 @@ describe('file content editing', () => { expect(canEditFileContent(true, 'abc123')).toBe(false); expect(canEditFileContent(false, null)).toBe(false); }); + + it('keys unsaved drafts only for working-tree files', () => { + expect(fileDraftKey('/repo', 'src/main.ts', null)).toBe('/repo\u0000src/main.ts'); + expect(fileDraftKey('/repo', 'src/main.ts', 'abc123')).toBe(null); + expect(fileDraftKey(null, 'src/main.ts', null)).toBe(null); + }); }); diff --git a/ui/src/lib/fileEditing.ts b/ui/src/lib/fileEditing.ts index 0e6d5a3..92f5c57 100644 --- a/ui/src/lib/fileEditing.ts +++ b/ui/src/lib/fileEditing.ts @@ -4,3 +4,8 @@ export function canEditFileContent(editable: boolean, revision: string | null): boolean { return editable && revision === null; } + +export function fileDraftKey(repoPath: string | null, path: string, revision: string | null): string | null { + if (!repoPath || revision !== null) return null; + return `${repoPath}\u0000${path}`; +} diff --git a/ui/src/views/FileView.tsx b/ui/src/views/FileView.tsx index bfd63d3..499d03d 100644 --- a/ui/src/views/FileView.tsx +++ b/ui/src/views/FileView.tsx @@ -6,8 +6,10 @@ import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, + type ComponentProps, } from 'react'; -import { File as PierreFile } from '@pierre/diffs/react'; +import { EditProvider, File as PierreFile } from '@pierre/diffs/react'; +import { Editor } from '@pierre/diffs/edit'; import { open as shellOpen } from '@tauri-apps/plugin-shell'; import { Diff } from '../components/Diff'; @@ -18,14 +20,13 @@ 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, fileDraftKey } 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 type { @@ -422,6 +423,7 @@ function ContentTab({ const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const scrollRef = useRef(null); + const draftKey = useMemo(() => fileDraftKey(repoPath, path, revision), [repoPath, path, revision]); const savingRef = useRef(false); const loadedSourceRef = useRef(null); const selectLine = useCallback((line: number | null) => setSelectedLine(line), []); @@ -484,7 +486,7 @@ function ContentTab({ if (!refreshingLoadedSource) { loadedSourceRef.current = null; setData(null); - setDraft(''); + if (!draftKey || !sessionFileDrafts.has(draftKey)) setDraft(''); setOriginal(''); setSaveError(null); savingRef.current = false; @@ -495,7 +497,8 @@ function ContentTab({ if (cancelled || (refreshingLoadedSource && dirtyRef.current)) return; loadedSourceRef.current = sourceKey; setData(c); - setDraft(normalizeEditorText(c.text)); + const normalized = normalizeEditorText(c.text); + setDraft(draftKey ? sessionFileDrafts.get(draftKey) ?? normalized : normalized); setOriginal(c.text); }) .catch((e) => { @@ -505,7 +508,7 @@ function ContentTab({ }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [repoPath, path, revision, refetchKey, sourceKey]); + }, [repoPath, path, revision, refetchKey, sourceKey, draftKey]); const save = useCallback(async () => { if (!repoPath || !data?.editable || revision || !dirty || savingRef.current) return; @@ -518,14 +521,19 @@ function ContentTab({ setData(saved); setOriginal(saved.text); // Do not clobber keystrokes entered while the write was in flight. - setDraft((current) => current === next ? normalizeEditorText(saved.text) : current); + setDraft((current) => { + const normalized = normalizeEditorText(saved.text); + if (current === next && draftKey) sessionFileDrafts.delete(draftKey); + else if (draftKey) sessionFileDrafts.set(draftKey, current); + return current === next ? normalized : current; + }); } catch (caught) { setSaveError(errMessage(caught)); } finally { savingRef.current = false; setSaving(false); } - }, [data?.editable, dirty, draft, original, path, repoPath, revision]); + }, [data?.editable, dirty, draft, original, path, repoPath, revision, draftKey]); useEffect(() => { if (!editable) return; @@ -558,6 +566,17 @@ function ContentTab({ // JSX to skip the excess-property check — same as MergeResolver). const opts = { theme: pierreTheme, disableBackground: true, disableFileHeader: true }; const searchText = editable ? draft : data.text; + const editorOptions = useMemo(() => ({ + persistState: true, + onChange: (next: string) => { + setDraft(next); + if (draftKey) { + if (next === normalizedOriginal) sessionFileDrafts.delete(draftKey); + else sessionFileDrafts.set(draftKey, next); + } + setSaveError(null); + }, + }), [draftKey, normalizedOriginal]); return (
{data.truncated && ( @@ -595,16 +614,17 @@ function ContentTab({
- { - setDraft(next); - setSaveError(null); - }} - /> +
+ + + +
) : (
@@ -619,114 +639,13 @@ 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]); +type PierreFileProps = ComponentProps; +type PierreEditOptions = NonNullable; - 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]); +const sessionFileDrafts = new Map(); - return ( -
-
-
{gutter}
-
-
- {selectedLine != null && ( -
- )} -
-          {highlighted}
-        
-