Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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;
},

Expand All @@ -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();
}
},

Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
69 changes: 59 additions & 10 deletions apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string | null>(null);
const dirtySyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const store = useEditorGroupsStore();
const groupsState = store.sessions[sessionKey] ?? createInitialGroupsState();
Expand All @@ -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(
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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" }]);
});
});
Original file line number Diff line number Diff line change
@@ -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<string>();
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<MonacoModelRegistry, "getValue" | "getSavedValue">,
): 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 };
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function CodeViewer({
theme,
registry,
onDirtyChange,
onBufferChange,
onEdit,
onRegisterEditorApi,
}: ViewerProps) {
Expand All @@ -28,8 +29,8 @@ export function CodeViewer({
const changeSubRef = useRef<Monaco.IDisposable | null>(null);
const dirtyTimerRef = useRef<ReturnType<typeof setTimeout> | 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<EditorApi | null>(null);
const registeredPathRef = useRef<string | null>(null);
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading