diff --git a/apps/desktop/src/renderer/components/files/monacoModelRegistry.test.ts b/apps/desktop/src/renderer/components/files/monacoModelRegistry.test.ts index 944bb0b8b..93e5ca7e8 100644 --- a/apps/desktop/src/renderer/components/files/monacoModelRegistry.test.ts +++ b/apps/desktop/src/renderer/components/files/monacoModelRegistry.test.ts @@ -116,6 +116,22 @@ describe("monacoModelRegistry", () => { } }); + it("exposes saved baseline text for agent dirty-buffer reads", () => { + const { monaco } = createFakeMonaco(); + const registry = createMonacoModelRegistry(); + + registry.getOrCreate(monaco, "a", "hello", "plaintext"); + expect(registry.getSavedValue("a")).toBe("hello"); + + const model = registry.getOrCreate(monaco, "a", "ignored", "plaintext") as any; + model.__edit(); + expect(registry.getValue("a")).toBe("hello"); + expect(registry.getSavedValue("a")).toBe("hello"); + + registry.markSaved("a"); + expect(registry.getSavedValue("a")).toBe("hello"); + }); + it("tracks dirty state against the last saved baseline", () => { const { monaco } = createFakeMonaco(); const registry = createMonacoModelRegistry(); diff --git a/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts b/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts index 6134701c5..c84a4fc81 100644 --- a/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts +++ b/apps/desktop/src/renderer/components/files/monacoModelRegistry.ts @@ -5,6 +5,8 @@ type Entry = { languageId: string; /** Alternative version id captured at last load/save; dirty = current !== this. */ baseVersionId: number; + /** Buffer text at the last clean baseline (load/save). Used for agent dirty-buffer reads. */ + savedContent: string; }; /** @@ -51,7 +53,12 @@ export function createMonacoModelRegistry() { return existing.model; } const model = monaco.editor.createModel(content, languageId); - models.set(path, { model, languageId, baseVersionId: model.getAlternativeVersionId() }); + models.set(path, { + model, + languageId, + baseVersionId: model.getAlternativeVersionId(), + savedContent: content, + }); return model; }, @@ -60,6 +67,7 @@ export function createMonacoModelRegistry() { const entry = models.get(path); if (entry && !entry.model.isDisposed()) { entry.baseVersionId = entry.model.getAlternativeVersionId(); + entry.savedContent = entry.model.getValue(); } }, @@ -77,6 +85,13 @@ export function createMonacoModelRegistry() { return entry.model.getValue(); }, + /** Last saved/loaded baseline text for `path`, or null when no model exists. */ + getSavedValue(path: string): string | null { + const entry = models.get(path); + if (!entry || entry.model.isDisposed()) return null; + return entry.savedContent; + }, + has(path: string): boolean { const entry = models.get(path); return Boolean(entry && !entry.model.isDisposed()); diff --git a/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx b/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx index 29294197a..9e4751cb6 100644 --- a/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx +++ b/apps/desktop/src/renderer/components/files/v2/EditorGroup.tsx @@ -29,6 +29,7 @@ export type EditorGroupProps = { onFocusGroup: (groupId: string) => void; onSplit: (groupId: string) => void; onDirtyChange: (path: string, dirty: boolean) => void; + onBufferChange: (path: string) => void; onTabDragStart: (groupId: string, path: string) => void; onTabDragEnd: () => void; onTabDrop: (groupId: string) => void; @@ -161,6 +162,7 @@ export function EditorGroup(props: EditorGroupProps) { theme={props.theme} registry={props.registry} onDirtyChange={props.onDirtyChange} + onBufferChange={props.onBufferChange} onEdit={(path) => props.onPromoteTab(group.id, path)} onRegisterEditorApi={registerApi} /> diff --git a/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx b/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx index adea1eb79..c7ca6ace9 100644 --- a/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx +++ b/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx @@ -30,6 +30,7 @@ export type EditorGroupsProps = { onFocusGroup: (groupId: string) => void; onSplit: (groupId: string) => void; onDirtyChange: (path: string, dirty: boolean) => void; + onBufferChange: (path: string) => void; onTabDragStart: (groupId: string, path: string) => void; onTabDragEnd: () => void; onTabDrop: (groupId: string) => void; @@ -90,6 +91,7 @@ export function EditorGroups(props: EditorGroupsProps) { onFocusGroup={props.onFocusGroup} onSplit={props.onSplit} onDirtyChange={props.onDirtyChange} + onBufferChange={props.onBufferChange} onTabDragStart={props.onTabDragStart} onTabDragEnd={props.onTabDragEnd} onTabDrop={props.onTabDrop} diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index e86ce8fb8..422bab770 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -40,7 +40,12 @@ import { CreatePromptModal, SearchOverlay } from "./overlays"; import { setPendingReveal } from "./pendingReveals"; import { COLORS } from "../../lanes/laneDesignTokens"; import { modifierKeyLabel } from "../../../lib/platform"; +import { + clearDirtyBuffersForWorkspace, + replaceDirtyBuffersForWorkspace, +} from "../../../lib/dirtyWorkspaceBuffers"; import type { EditorThemeMode } from "./viewers/types"; +import { buildDirtyBufferTabs, collectOpenTabPaths } from "./filesWorkbenchDirtySync"; const TREE_PAGE_SIZE = 2_000; const MAX_AUTO_LOADED_CHILDREN = 10_000; @@ -110,6 +115,8 @@ export function FilesWorkbench({ const dragRef = useRef<{ groupId: string; path: string } | null>(null); const workspaceIdRef = useRef(workspaceId); workspaceIdRef.current = workspaceId; + const dirtyWorkspaceRootRef = useRef(null); + const dirtySyncTimerRef = useRef | null>(null); const store = useEditorGroupsStore(); const groupsState = store.sessions[sessionKey] ?? createInitialGroupsState(); @@ -118,6 +125,39 @@ export function FilesWorkbench({ [store, sessionKey], ); + const syncDirtyBuffersToAgent = useCallback(() => { + const nextRootPath = rootPath || null; + if (!nextRootPath) return; + const openPaths = collectOpenTabPaths(groupsState); + replaceDirtyBuffersForWorkspace(nextRootPath, buildDirtyBufferTabs(openPaths, registryRef.current)); + }, [rootPath, groupsState]); + + const scheduleDirtyBuffersSync = useCallback(() => { + if (dirtySyncTimerRef.current) clearTimeout(dirtySyncTimerRef.current); + dirtySyncTimerRef.current = setTimeout(() => { + dirtySyncTimerRef.current = null; + syncDirtyBuffersToAgent(); + }, 100); + }, [syncDirtyBuffersToAgent]); + + useEffect(() => { + const nextRootPath = rootPath || null; + const previousRootPath = dirtyWorkspaceRootRef.current; + if (previousRootPath && previousRootPath !== nextRootPath) { + clearDirtyBuffersForWorkspace(previousRootPath); + } + dirtyWorkspaceRootRef.current = nextRootPath; + syncDirtyBuffersToAgent(); + }, [rootPath, groupsState, dirtyPaths, syncDirtyBuffersToAgent]); + + useEffect(() => { + return () => { + if (dirtySyncTimerRef.current) clearTimeout(dirtySyncTimerRef.current); + const root = dirtyWorkspaceRootRef.current; + if (root) clearDirtyBuffersForWorkspace(root); + }; + }, []); + const activeGroup = groupsState.groups[groupsState.activeGroupId]; const activeTab = activeGroup?.tabs.find((t) => t.path === activeGroup.activeTabId) ?? null; const openCount = useMemo( @@ -367,16 +407,24 @@ export function FilesWorkbench({ [applyGroups, dirtyPaths], ); - const handleDirtyChange = useCallback((path: string, dirty: boolean) => { - setDirtyPaths((prev) => { - const has = prev.has(path); - if (has === dirty) return prev; - const next = new Set(prev); - if (dirty) next.add(path); - else next.delete(path); - return next; - }); - }, []); + const handleDirtyChange = useCallback( + (path: string, dirty: boolean) => { + setDirtyPaths((prev) => { + const has = prev.has(path); + if (has === dirty) return prev; + const next = new Set(prev); + if (dirty) next.add(path); + else next.delete(path); + return next; + }); + scheduleDirtyBuffersSync(); + }, + [scheduleDirtyBuffersSync], + ); + + const handleBufferChange = useCallback(() => { + scheduleDirtyBuffersSync(); + }, [scheduleDirtyBuffersSync]); const handleTabDragStart = useCallback((groupId: string, path: string) => { dragRef.current = { groupId, path }; @@ -607,6 +655,7 @@ export function FilesWorkbench({ onFocusGroup={(groupId) => applyGroups((s) => ({ ...s, activeGroupId: groupId }))} onSplit={(groupId) => applyGroups((s) => splitGroup(s, groupId))} onDirtyChange={handleDirtyChange} + onBufferChange={handleBufferChange} onTabDragStart={handleTabDragStart} onTabDragEnd={handleTabDragEnd} onTabDrop={handleTabDrop} diff --git a/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx b/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx index 3906bb15c..29e8b2038 100644 --- a/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx +++ b/apps/desktop/src/renderer/components/files/v2/ViewerHost.tsx @@ -21,6 +21,7 @@ export type ViewerHostProps = { registry: MonacoModelRegistry; reloadToken?: number; onDirtyChange?: (path: string, dirty: boolean) => void; + onBufferChange?: (path: string) => void; onEdit?: (path: string) => void; onRegisterEditorApi?: (path: string, api: EditorApi | null) => void; }; @@ -49,6 +50,7 @@ export function ViewerHost(props: ViewerHostProps) { theme: props.theme, registry: props.registry, onDirtyChange: props.onDirtyChange, + onBufferChange: props.onBufferChange, onEdit: props.onEdit, onRegisterEditorApi: props.onRegisterEditorApi, }; diff --git a/apps/desktop/src/renderer/components/files/v2/filesWorkbenchDirtySync.test.ts b/apps/desktop/src/renderer/components/files/v2/filesWorkbenchDirtySync.test.ts new file mode 100644 index 000000000..83da654b2 --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/filesWorkbenchDirtySync.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { createInitialGroupsState, openInGroup, type EditorTab } from "./editorGroupsStore"; +import { buildDirtyBufferTabs, collectOpenTabPaths } from "./filesWorkbenchDirtySync"; +import { createMonacoModelRegistry } from "../monacoModelRegistry"; + +function createFakeMonaco() { + const makeModel = (content: string, languageId: string) => { + let disposed = false; + let version = 1; + let value = content; + return { + languageId, + isDisposed: () => disposed, + getValue: () => value, + setValue: (next: string) => { + value = next; + version += 1; + }, + getAlternativeVersionId: () => version, + dispose: () => { + disposed = true; + }, + }; + }; + return { + editor: { + createModel: (content: string, languageId: string) => makeModel(content, languageId), + setModelLanguage: () => {}, + }, + } as any; +} + +describe("filesWorkbenchDirtySync", () => { + it("collects open tab paths from all groups", () => { + const tab: EditorTab = { + path: "src/a.ts", + title: "a.ts", + viewerKind: "code", + languageId: "typescript", + preview: false, + pinned: false, + }; + let state = createInitialGroupsState(); + state = openInGroup(state, state.activeGroupId, tab, { preview: false }); + expect(collectOpenTabPaths(state)).toEqual(["src/a.ts"]); + }); + + it("builds dirty buffer tabs from monaco registry content and saved baseline", () => { + const monaco = createFakeMonaco(); + const registry = createMonacoModelRegistry(); + const model = registry.getOrCreate(monaco, "src/a.ts", "saved", "typescript") as { + setValue: (next: string) => void; + }; + model.setValue("dirty"); + const tabs = buildDirtyBufferTabs(["src/a.ts"], registry); + expect(tabs).toEqual([{ path: "src/a.ts", content: "dirty", savedContent: "saved" }]); + }); +}); diff --git a/apps/desktop/src/renderer/components/files/v2/filesWorkbenchDirtySync.ts b/apps/desktop/src/renderer/components/files/v2/filesWorkbenchDirtySync.ts new file mode 100644 index 000000000..cacdebafd --- /dev/null +++ b/apps/desktop/src/renderer/components/files/v2/filesWorkbenchDirtySync.ts @@ -0,0 +1,25 @@ +import type { GroupsState } from "./editorGroupsStore"; +import type { MonacoModelRegistry } from "../monacoModelRegistry"; + +/** Collect workspace-relative paths for every open tab across editor groups. */ +export function collectOpenTabPaths(groupsState: GroupsState): string[] { + const paths = new Set(); + for (const groupId of groupsState.groupOrder) { + const group = groupsState.groups[groupId]; + if (!group) continue; + for (const tab of group.tabs) paths.add(tab.path); + } + return [...paths]; +} + +/** Build the tab snapshot consumed by `replaceDirtyBuffersForWorkspace`. */ +export function buildDirtyBufferTabs( + openPaths: readonly string[], + registry: Pick, +): ReadonlyArray<{ path: string; content: string; savedContent: string }> { + return openPaths.map((path) => { + const content = registry.getValue(path) ?? ""; + const savedContent = registry.getSavedValue(path) ?? content; + return { path, content, savedContent }; + }); +} diff --git a/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx b/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx index 41f85e7be..b4208292e 100644 --- a/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx +++ b/apps/desktop/src/renderer/components/files/v2/viewers/CodeViewer.tsx @@ -19,6 +19,7 @@ export function CodeViewer({ theme, registry, onDirtyChange, + onBufferChange, onEdit, onRegisterEditorApi, }: ViewerProps) { @@ -28,8 +29,8 @@ export function CodeViewer({ const changeSubRef = useRef(null); const dirtyTimerRef = useRef | null>(null); // Latest props for use inside long-lived Monaco callbacks. - const ctxRef = useRef({ workspaceId, tab, registry, onDirtyChange, onEdit, onRegisterEditorApi, readOnly }); - ctxRef.current = { workspaceId, tab, registry, onDirtyChange, onEdit, onRegisterEditorApi, readOnly }; + const ctxRef = useRef({ workspaceId, tab, registry, onDirtyChange, onBufferChange, onEdit, onRegisterEditorApi, readOnly }); + ctxRef.current = { workspaceId, tab, registry, onDirtyChange, onBufferChange, onEdit, onRegisterEditorApi, readOnly }; const apiRef = useRef(null); const registeredPathRef = useRef(null); @@ -160,8 +161,10 @@ export function CodeViewer({ } changeSubRef.current?.dispose(); changeSubRef.current = model.onDidChangeContent(() => { - const { tab: t, registry: reg, onDirtyChange: onDirty, onEdit: onEditCb } = ctxRef.current; + const { tab: t, registry: reg, onDirtyChange: onDirty, onBufferChange: onBuffer, onEdit: onEditCb } = + ctxRef.current; onEditCb?.(t.path); // first edit promotes a preview tab to permanent + onBuffer?.(t.path); if (dirtyTimerRef.current) clearTimeout(dirtyTimerRef.current); dirtyTimerRef.current = setTimeout(() => { onDirty?.(t.path, reg.isDirty(t.path)); diff --git a/apps/desktop/src/renderer/components/files/v2/viewers/types.ts b/apps/desktop/src/renderer/components/files/v2/viewers/types.ts index 668fe8523..00e090e20 100644 --- a/apps/desktop/src/renderer/components/files/v2/viewers/types.ts +++ b/apps/desktop/src/renderer/components/files/v2/viewers/types.ts @@ -29,6 +29,8 @@ export type ViewerProps = { registry: MonacoModelRegistry; /** Notify the shell that the in-editor buffer became dirty/clean (code viewer only). */ onDirtyChange?: (path: string, dirty: boolean) => void; + /** Notify the shell on each buffer edit (agent dirty-buffer sync; code viewer only). */ + onBufferChange?: (path: string) => void; /** Promote a preview tab to permanent on first edit. */ onEdit?: (path: string) => void; /** Register/unregister the editor's imperative API for toolbar actions. */