Skip to content
Open
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
32 changes: 28 additions & 4 deletions apps/docs/app/(diffs)/_docs/DocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@ import {
CUSTOM_HUNK_SEPARATORS_SWITCHER,
} from '../docs/CustomHunkSeparators/constants';
import {
EDIT_DEMO_FILE_EXAMPLE,
EDIT_FOCUS_POSITION_EXAMPLE,
EDIT_LAZY_FILE_EXAMPLE,
EDIT_MARKER_EXAMPLE,
EDIT_MARKER_TYPE,
EDIT_ON_ATTACH_REACT_EXAMPLE,
EDIT_ON_ATTACH_VANILLA_EXAMPLE,
EDIT_ON_CHANGE_EXAMPLE,
EDIT_PERSIST_STATE_EXAMPLE,
EDIT_PERSIST_STATE_REACT_EXAMPLE,
EDIT_REACT_CODE_VIEW_EXAMPLE,
EDIT_REACT_CREATE_EDITOR_EXAMPLE,
EDIT_REACT_EXAMPLE,
EDIT_REACT_FILE_DIFF_EXAMPLE,
EDIT_REACT_MULTI_FILE_DIFF_EXAMPLE,
EDIT_REACT_SHARED_EDITOR_EXAMPLE,
EDIT_SELECTION_ACTION_CONTEXT_TYPE,
EDIT_SELECTION_ACTION_EXAMPLE,
EDIT_UNDO_REDO_EXAMPLE,
Expand Down Expand Up @@ -424,19 +430,25 @@ async function CodeViewSection() {

async function EditSection() {
const [
editDemoFile,
keymapFile,
editVanillaFileExample,
editVanillaFileDiffExample,
editVanillaCodeViewExample,
editLazyFileExample,
editorOptionsType,
editOnChangeExample,
editOnAttachReactExample,
editOnAttachVanillaExample,
editFocusPositionExample,
editorPublicApi,
editSelectionActionContextType,
editSelectionActionExample,
editPersistStateExample,
editPersistStateReactExample,
editMarkerType,
editMarkerExample,
editReactCreateEditorExample,
editReactSharedEditorExample,
editReactCodeViewExample,
editReactExample,
editReactFileDiffExample,
Expand All @@ -445,19 +457,25 @@ async function EditSection() {
editWorkerPoolReactExample,
editWorkerPoolVanillaExample,
] = await Promise.all([
preloadFile(EDIT_DEMO_FILE_EXAMPLE),
preloadFile(DEFAULT_KEYMAP_FILE_EXAMPLE),
preloadFile(EDIT_VANILLA_FILE_EXAMPLE),
preloadFile(EDIT_VANILLA_FILE_DIFF_EXAMPLE),
preloadFile(EDIT_VANILLA_CODE_VIEW_EXAMPLE),
preloadFile(EDIT_LAZY_FILE_EXAMPLE),
preloadFile(EDITOR_OPTIONS_TYPE),
preloadFile(EDIT_ON_CHANGE_EXAMPLE),
preloadFile(EDIT_ON_ATTACH_REACT_EXAMPLE),
preloadFile(EDIT_ON_ATTACH_VANILLA_EXAMPLE),
preloadFile(EDIT_FOCUS_POSITION_EXAMPLE),
preloadFile(EDITOR_PUBLIC_API),
preloadFile(EDIT_SELECTION_ACTION_CONTEXT_TYPE),
preloadFile(EDIT_SELECTION_ACTION_EXAMPLE),
preloadFile(EDIT_PERSIST_STATE_EXAMPLE),
preloadFile(EDIT_PERSIST_STATE_REACT_EXAMPLE),
preloadFile(EDIT_MARKER_TYPE),
preloadFile(EDIT_MARKER_EXAMPLE),
preloadFile(EDIT_REACT_CREATE_EDITOR_EXAMPLE),
preloadFile(EDIT_REACT_SHARED_EDITOR_EXAMPLE),
preloadFile(EDIT_REACT_CODE_VIEW_EXAMPLE),
preloadFile(EDIT_REACT_EXAMPLE),
preloadFile(EDIT_REACT_FILE_DIFF_EXAMPLE),
Expand All @@ -469,19 +487,25 @@ async function EditSection() {
const content = await renderMDX({
filePath: '(diffs)/docs/Edit/content.mdx',
scope: {
editDemoFile,
keymapFile,
editVanillaFileExample,
editVanillaFileDiffExample,
editVanillaCodeViewExample,
editLazyFileExample,
editorOptionsType,
editOnChangeExample,
editOnAttachReactExample,
editOnAttachVanillaExample,
editFocusPositionExample,
editorPublicApi,
editSelectionActionContextType,
editSelectionActionExample,
editPersistStateExample,
editPersistStateReactExample,
editMarkerType,
editMarkerExample,
editReactCreateEditorExample,
editReactSharedEditorExample,
editReactCodeViewExample,
editReactExample,
editReactFileDiffExample,
Expand Down
180 changes: 62 additions & 118 deletions apps/docs/app/(diffs)/_home/AgentUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { DEFAULT_THEMES, type FileDiffMetadata } from '@pierre/diffs';
import type { EditorOptions } from '@pierre/diffs/edit';
import { File, FileDiff } from '@pierre/diffs/react';
import { File, FileDiff, Virtualizer } from '@pierre/diffs/react';
import {
IconArrow,
IconChevronSm,
Expand All @@ -21,7 +21,6 @@ import {
type CSSProperties,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand All @@ -41,12 +40,6 @@ import {
getSessionGitStatus,
getSessionPaths,
} from './mockData';
// Runs as a layout effect in the browser (so DOM reads/writes land before the
// next paint) but falls back to useEffect during SSR, where useLayoutEffect
// would warn. The demo is server-rendered, so the fallback matters.
const useIsomorphicLayoutEffect =
typeof window === 'undefined' ? useEffect : useLayoutEffect;

// Added/removed line totals for a single file's diff.
interface DiffStats {
additions: number;
Expand All @@ -66,33 +59,6 @@ function countDiffStats(diff: FileDiffMetadata): DiffStats {
return { additions, deletions };
}

// The editor's stylesheet flattens every line number to one neutral colour
// (`--diffs-editor-line-number-fg`) and is injected as an unlayered <style>,
// so it overrides the library's per-line colouring (which lives in @layer
// base). We adopt this extra, higher-specificity unlayered sheet into the
// editor's shadow root to restore jade/red numbers for added and deleted
// lines, while leaving the active/selected line to the editor's own styling.
const LINE_NUMBER_COLOR_CSS = `
[data-column-number][data-line-type='change-addition']:not([data-selected-line]):not([data-editor-active-line]) {
color: var(--diffs-addition-base);
}
[data-column-number][data-line-type='change-deletion']:not([data-selected-line]):not([data-editor-active-line]) {
color: var(--diffs-deletion-base);
}
`;

let lineNumberColorSheet: CSSStyleSheet | null = null;
function getLineNumberColorSheet(): CSSStyleSheet | null {
if (typeof CSSStyleSheet === 'undefined') {
return null;
}
if (lineNumberColorSheet == null) {
lineNumberColorSheet = new CSSStyleSheet();
lineNumberColorSheet.replaceSync(LINE_NUMBER_COLOR_CSS);
}
return lineNumberColorSheet;
}

// `renderSelectionAction` returns a plain DOM node, not React, and renders into
// the editor's shadow DOM where the page's CSS (including agent-ui.css) doesn't
// reach, so the comment icon is inlined as markup painted with `currentColor`
Expand Down Expand Up @@ -452,28 +418,6 @@ function makeAddedFile(path: string): AuiChangedFile {
};
}

// A placeholder file (one outside the agent's change set) that the user edited,
// modeled as a "modified" change diffing the original placeholder contents
// against the live edits. This surfaces it in the Changes panel with tracked
// +/- counts even though it was never part of the original session.
function makeEditedPlaceholder(path: string, after: string): AuiChangedFile {
const before = getPlaceholderContents(path);
const stats = countDiffStats(
getFileDiff(
{
path,
status: 'modified',
before,
after: before,
additions: 0,
deletions: 0,
},
after
)
);
return { path, status: 'modified', before, after, ...stats };
}

// Toolbar above the file explorer: New file, New folder, and the search toggle.
// Lives in its own component (rendered only once we have a model) so
// useFileTreeSearch — which subscribes to the model — is never called against a
Expand Down Expand Up @@ -1026,27 +970,44 @@ export function AgentUi({
const recordEditedStatsRef = useRef(recordEditedStats);
recordEditedStatsRef.current = recordEditedStats;

// Persisted in-editor edits keyed by path, so switching files keeps the
// agent's tweaked output.
const editsRef = useRef<Map<string, string>>(new Map());
// One FileDiffMetadata per changed file, parsed on first visit and reused
// on every revisit. Edit sessions write edits back into the metadata (the
// library treats the host's metadata as the diff's content owner and
// self-heals session-shaped metadata on re-render), so reusing the object
// is what keeps a diff's edited content across file switches — the editor's
// persist-state API covers only selections and scroll for diffs.
// Placeholder File surfaces need no equivalent: with `persistState` on the
// shared editor, the per-cacheKey document cache restores their edited
// contents (and undo history) on re-attach.
const diffsRef = useRef<Map<string, FileDiffMetadata>>(new Map());
// Paths the user has edited. Only consulted to stop an edited file from
// hydrating out of its prerendered (pristine) server HTML on revisit.
const editedPathsRef = useRef<Set<string>>(new Set());
// The stable onChange callback has no path argument, so track its live target
// here.
const activeTargetRef = useRef<string | null>(null);
useEffect(() => {
activeTargetRef.current = activePath;
}, [activePath]);

// Edited placeholder files modeled as "modified" changes from their live
// edits. Recomputed when the tracked set changes (each edit also refreshes the
// displayed counts via `liveStats`, so the row decoration stays current).
// Edited placeholder files listed in the Changes panel as "modified". The
// entries carry zero snapshot counts because the tree's row decoration
// always finds live counts in `liveStats` for tracked placeholders; the
// edited contents themselves live in the shared editor's persist-state
// document cache, not here.
const editedPlaceholderFiles = useMemo<AuiChangedFile[]>(
() =>
editedPlaceholders.map((path) =>
makeEditedPlaceholder(
editedPlaceholders.map((path) => {
const before = getPlaceholderContents(path);
return {
path,
editsRef.current.get(path) ?? getPlaceholderContents(path)
)
),
status: 'modified' as const,
before,
after: before,
additions: 0,
deletions: 0,
};
}),
[editedPlaceholders]
);

Expand All @@ -1064,6 +1025,7 @@ export function AgentUi({

const editorOptions = useMemo<EditorOptions<undefined>>(
() => ({
persistState: true,
enabledSelectionAction: true,
renderSelectionAction(selectionAction) {
const container = document.createElement('div');
Expand Down Expand Up @@ -1109,7 +1071,7 @@ export function AgentUi({
if (target == null) {
return;
}
editsRef.current.set(target, file.contents);
editedPathsRef.current.add(target);
// Recompute the edited file's diff against its original snapshot so the
// Changes tree's +/- totals reflect the live edits.
recordEditedStatsRef.current(target, file.contents);
Expand Down Expand Up @@ -1143,57 +1105,30 @@ export function AgentUi({
[activePath, activeFile]
);

const editKey = activeFile?.path ?? '';

// Rebuild the diff surface whenever the active file changes, substituting any
// persisted edits for the snapshot's `after`.
const fileDiff = useMemo(
() =>
activeFile != null
? getFileDiff(activeFile, editsRef.current.get(editKey))
: null,
[activeFile, editKey]
);
// The active file's diff metadata: parsed once on first visit, then reused
// from the cache so revisits render the content the last edit session wrote
// back into it.
const fileDiff = useMemo(() => {
if (activeFile == null) {
return null;
}
let diff = diffsRef.current.get(activeFile.path);
if (diff == null) {
diff = getFileDiff(activeFile);
diffsRef.current.set(activeFile.path, diff);
}
return diff;
}, [activeFile]);

// Server-rendered, already-highlighted HTML for the active diff. Only safe
// when the file is unedited so the markup matches `fileDiff`.
const activePrerenderedHTML =
activePath != null && editsRef.current.get(editKey) == null
activePath != null && !editedPathsRef.current.has(activePath)
? prerenderedDiffs?.[activePath]
: undefined;

const breadcrumbSegments = activePath != null ? activePath.split('/') : [];

// Re-adopt the jade/red line-number override whenever the diff surface is
// rebuilt (each file switch remounts the diffs-container with a fresh shadow
// root).
const surfaceWrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const sheet = getLineNumberColorSheet();
if (sheet == null) {
return;
}
const container = surfaceWrapRef.current?.querySelector('.aui-surface');
const shadowRoot = container?.shadowRoot;
if (shadowRoot == null) {
return;
}
if (!shadowRoot.adoptedStyleSheets.includes(sheet)) {
shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, sheet];
}
}, [activePath]);

// `key={activePath}` remounts the FileDiff or File surface for each file while
// `.aui-surface-wrap` remains mounted and retains its scroll offset. Reset the
// outer host after the new surface is laid out but before paint. Editing a
// file does not change `activePath`, so this never disturbs a session.
useIsomorphicLayoutEffect(() => {
const wrap = surfaceWrapRef.current;
if (wrap != null) {
wrap.scrollTop = 0;
}
}, [activePath]);

const changedCount = changesSession.changedFiles.length;

return (
Expand Down Expand Up @@ -1268,7 +1203,14 @@ export function AgentUi({
</nav>
</header>

<div className="aui-surface-wrap" ref={surfaceWrapRef}>
<Virtualizer
className="aui-surface-wrap"
contentStyle={{
display: 'flex',
minHeight: '100%',
width: '100%',
}}
>
{activeFile != null && fileDiff != null ? (
<FileDiff
key={activePath}
Expand All @@ -1281,17 +1223,19 @@ export function AgentUi({
/>
) : placeholderContents != null && activePath != null ? (
// Editable view for explorer files that aren't part of the change
// set (e.g. the root README or a generated stub). The app-level
// provider creates an independent editor for this keyed surface.
// Caller-owned `editsRef` seeds its contents when revisited.
// set (e.g. the root README or a generated stub). Always mounts
// with the pristine placeholder contents: `cacheKey` is required
// by the shared editor's `persistState`, whose per-file document
// cache substitutes any previously edited contents (and their
// undo history) when the surface re-attaches.
// Highlighted on the main thread since this File is mounted
// dynamically outside the editable surface's worker pool.
<File
key={activePath}
file={{
name: activePath,
contents:
editsRef.current.get(activePath) ?? placeholderContents,
cacheKey: activePath,
contents: placeholderContents,
}}
className="aui-surface"
options={{
Expand All @@ -1307,7 +1251,7 @@ export function AgentUi({
) : (
<div className="aui-empty">Select a file to review.</div>
)}
</div>
</Virtualizer>

<div className="aui-composer">
{snippets.length > 0 && (
Expand Down
Loading