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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 12 additions & 10 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<File edit>` surface and save through stale-checked `repo_file_write`; revisions,
binaries, oversized files, and non-UTF-8 text stay on Pierre's read-only
`<File>`. 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.
`<File>`. 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 `<img>`), markdown through `lib/markdown.tsx`
Expand Down
35 changes: 18 additions & 17 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<File edit>`
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
Expand Down
63 changes: 47 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions ui/src/components/PierreFileEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<undefined>;
path: string;
selectedLine: number | null;
text: string;
onChange(text: string): void;
}

function createEditor(options: EditorOptions<undefined>): Editor<undefined> {
return new Editor<undefined>(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<EditorOptions<undefined>>(
() => ({ onChange: (changed) => onChange(changed.contents) }),
[onChange],
);

return (
<EditProvider createEditor={createEditor}>
<File
file={file}
options={options}
edit
editorOptions={editorOptions}
selectedLines={selectedLine == null ? undefined : { start: selectedLine, end: selectedLine }}
/>
</EditProvider>
);
}
38 changes: 37 additions & 1 deletion ui/src/lib/fileEditing.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
});
});
47 changes: 47 additions & 0 deletions ui/src/lib/fileEditing.ts
Original file line number Diff line number Diff line change
@@ -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<string, WorkFileDraft>,
repoPath: string,
change: FilesTreeMutationChange,
): Record<string, WorkFileDraft> {
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. */
Expand Down
Loading
Loading