Skip to content

Commit e5add05

Browse files
committed
feat: persist open editor state on server
1 parent 6f441fb commit e5add05

22 files changed

Lines changed: 787 additions & 42 deletions

packages/core/src/domain/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export interface UiState {
7272
activeSessionId?: string;
7373
paneLayout?: WorkspacePaneNode;
7474
fileTreeExpandedDirs?: string[];
75+
openEditorPaths?: string[];
76+
activeEditorPath?: string | null;
7577
}
7678

7779
export interface WorkspaceLastViewedTarget {

packages/server/src/__tests__/workspace-commands.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,51 @@ describe("Workspace Commands", () => {
547547
.fileTreeExpandedDirs
548548
).toEqual(["packages", "packages/web"]);
549549
});
550+
551+
it("persists open editor paths and the active editor into workspace ui state", async () => {
552+
const dir = join(tmpdir(), `workspace-open-editors-test-${Date.now()}`);
553+
await mkdir(dir);
554+
555+
const openResult = await dispatch(
556+
{
557+
kind: "command",
558+
id: "open-workspace-open-editors",
559+
op: "workspace.open",
560+
args: { path: dir },
561+
},
562+
ctx
563+
);
564+
565+
expect(openResult.ok).toBe(true);
566+
const workspaceId = (openResult.data as { id: string }).id;
567+
568+
const result = await dispatch(
569+
{
570+
kind: "command",
571+
id: "set-ui-state-open-editors",
572+
op: "workspace.uiState.set",
573+
args: {
574+
workspaceId,
575+
uiState: {
576+
leftPanelWidth: 320,
577+
bottomPanelHeight: 210,
578+
focusMode: false,
579+
openEditorPaths: ["README.md", "src/app.tsx"],
580+
activeEditorPath: "src/app.tsx",
581+
},
582+
},
583+
},
584+
ctx
585+
);
586+
587+
expect(result.ok).toBe(true);
588+
expect(
589+
(result.data as { uiState: { openEditorPaths?: string[] } }).uiState.openEditorPaths
590+
).toEqual(["README.md", "src/app.tsx"]);
591+
expect(
592+
(result.data as { uiState: { activeEditorPath?: string | null } }).uiState.activeEditorPath
593+
).toBe("src/app.tsx");
594+
});
550595
});
551596

552597
describe("workspace.lastViewedTarget", () => {

packages/server/src/commands/workspace.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ registerCommand(
200200
activeSessionId: z.string().optional(),
201201
fileTreeExpandedDirs: z.array(z.string()).optional(),
202202
paneLayout: workspacePaneNodeSchema.optional(),
203+
openEditorPaths: z.array(z.string()).optional(),
204+
activeEditorPath: z.string().nullable().optional(),
203205
}),
204206
}),
205207
async (args, ctx) => {

packages/web/src/app/providers.test.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import { paneLayoutAtomFamily } from "../features/agent-panes/atoms/pane-layout"
1313
import { supervisorsAtom } from "../features/supervisor/atoms";
1414
import { terminalMetaAtomFamily } from "../features/terminal-panel/atoms";
1515
import { updateStateAtom } from "../features/updates/atoms";
16-
import { fileTreeStaleAtomFamily } from "../features/workspace/atoms";
16+
import {
17+
activeFilePathAtomFamily,
18+
fileTreeStaleAtomFamily,
19+
openEditorPathsAtomFamily,
20+
} from "../features/workspace/atoms";
1721
import { resetAppProvidersSingletonsForTests, routeEventToAtom } from "./providers";
1822

1923
describe("routeEventToAtom", () => {
@@ -203,6 +207,41 @@ describe("routeEventToAtom", () => {
203207
});
204208
});
205209

210+
it("projects workspace open editor metadata into editor atoms", () => {
211+
const store = createStore();
212+
213+
routeEventToAtom(
214+
"workspace.ws-1.meta",
215+
{
216+
path: "/tmp/ws-1",
217+
targetRuntime: "native",
218+
uiState: {
219+
leftPanelWidth: 280,
220+
bottomPanelHeight: 200,
221+
focusMode: false,
222+
openEditorPaths: ["src/app.tsx", "README.md", "src/app.tsx", ""],
223+
activeEditorPath: "src/app.tsx",
224+
},
225+
},
226+
store
227+
);
228+
229+
expect(store.get(openEditorPathsAtomFamily("ws-1"))).toEqual(["src/app.tsx", "README.md"]);
230+
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/app.tsx");
231+
});
232+
233+
it("keeps local open editor metadata when a workspace meta patch omits editor fields", () => {
234+
const store = createStore();
235+
store.set(openEditorPathsAtomFamily("ws-1"), ["src/current.ts"]);
236+
store.set(activeFilePathAtomFamily("ws-1"), "src/current.ts");
237+
238+
routeEventToAtom("workspace.ws-1.meta", { path: "/tmp/ws-1", targetRuntime: "native" }, store);
239+
routeEventToAtom("workspace.ws-1.meta", { name: "Renamed workspace" }, store);
240+
241+
expect(store.get(openEditorPathsAtomFamily("ws-1"))).toEqual(["src/current.ts"]);
242+
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/current.ts");
243+
});
244+
206245
it("marks the file tree stale when an fs.dirty event arrives", () => {
207246
const store = createStore();
208247

packages/web/src/app/providers.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ import {
7272
setGlobalRecoveryCoordinator,
7373
} from "../features/terminal-panel/recovery-singleton";
7474
import { updateStateAtom } from "../features/updates/atoms";
75+
import {
76+
hydrateWorkspaceEditorState,
77+
normalizeWorkspaceEditorUiState,
78+
} from "../features/workspace/actions/open-editor-state";
7579
import {
7680
editorRefreshTokenAtomFamily,
7781
expandedDirsAtomFamily,
@@ -1274,18 +1278,27 @@ export function routeEventToAtom(topic: string, payload: unknown, store: Store):
12741278
return;
12751279
}
12761280

1281+
const normalizedPatch: Partial<Workspace> = patch.uiState
1282+
? {
1283+
...patch,
1284+
uiState: normalizeWorkspaceEditorUiState(patch.uiState),
1285+
}
1286+
: patch;
1287+
12771288
store.set(workspacesAtom, (prev: Record<string, Workspace>) => ({
12781289
...prev,
12791290
[workspaceId]: {
12801291
...prev[workspaceId],
1281-
...patch,
1292+
...normalizedPatch,
12821293
id: workspaceId,
12831294
} as Workspace,
12841295
}));
1285-
const paneLayout = patch.uiState?.paneLayout;
1286-
if (paneLayout) {
1287-
store.set(paneLayoutAtomFamily(workspaceId), normalizePaneLayout(paneLayout));
1296+
const paneLayout = normalizedPatch.uiState?.paneLayout;
1297+
const normalizedPaneLayout = paneLayout ? normalizePaneLayout(paneLayout) : null;
1298+
if (normalizedPaneLayout) {
1299+
store.set(paneLayoutAtomFamily(workspaceId), normalizedPaneLayout);
12881300
}
1301+
hydrateWorkspaceEditorState(store, workspaceId, normalizedPatch.uiState);
12891302
store.set(workspaceOrderAtom, (prev: string[]) => {
12901303
if (prev.includes(workspaceId)) {
12911304
return prev;

packages/web/src/features/diagnostics/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { assignSessionToPane } from "../agent-panes/pane-layout-tree";
3232
import { MobilePageHeader } from "../shared/components/mobile-page-header";
3333
import { PageHeader } from "../shared/components/page-header";
34+
import { hydrateWorkspaceEditorState } from "../workspace/actions/open-editor-state";
3435
import { usePersistWorkspaceLastViewedTarget } from "../workspace/actions/use-persist-workspace-last-viewed-target";
3536
import { useWorkspaceUiStatePersistence } from "../workspace/actions/use-workspace-ui-state-persistence";
3637
import { useSystemDependencyInstaller } from "./actions/use-system-dependency-installer";
@@ -338,6 +339,7 @@ export function DiagnosticsPage() {
338339
...prev,
339340
[result.data!.id]: result.data!,
340341
}));
342+
hydrateWorkspaceEditorState(store, result.data.id, result.data.uiState);
341343
setWorkspaceOrder((prev) => {
342344
if (prev.includes(result.data!.id)) {
343345
return prev;
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { Workspace } from "@coder-studio/core";
2+
import type { Store } from "jotai/vanilla/store";
3+
import { activeFilePathAtomFamily, openEditorPathsAtomFamily } from "../atoms";
4+
5+
function hasOwnProperty<T extends object>(value: T, key: PropertyKey): boolean {
6+
return Object.prototype.hasOwnProperty.call(value, key);
7+
}
8+
9+
export function normalizeOpenEditorPaths(value: unknown): string[] {
10+
if (!Array.isArray(value)) {
11+
return [];
12+
}
13+
14+
const seen = new Set<string>();
15+
const next: string[] = [];
16+
17+
for (const entry of value) {
18+
if (typeof entry !== "string" || entry.trim().length === 0 || seen.has(entry)) {
19+
continue;
20+
}
21+
22+
seen.add(entry);
23+
next.push(entry);
24+
}
25+
26+
return next;
27+
}
28+
29+
export function normalizeActiveEditorPath(value: unknown): string | null {
30+
if (typeof value !== "string" || value.trim().length === 0) {
31+
return null;
32+
}
33+
34+
return value;
35+
}
36+
37+
export function mergeOpenEditorPaths(...pathLists: Array<Iterable<string> | undefined>): string[] {
38+
return normalizeOpenEditorPaths(pathLists.flatMap((paths) => (paths ? Array.from(paths) : [])));
39+
}
40+
41+
export function appendOpenEditorPath(paths: Iterable<string>, path: string): string[] {
42+
return mergeOpenEditorPaths(paths, [path]);
43+
}
44+
45+
export function removeOpenEditorPaths(
46+
paths: Iterable<string>,
47+
removedPaths: Iterable<string>
48+
): string[] {
49+
const removed = new Set(removedPaths);
50+
return normalizeOpenEditorPaths(Array.from(paths)).filter((path) => !removed.has(path));
51+
}
52+
53+
export function rewriteDescendantEditorPath(
54+
path: string,
55+
fromPath: string,
56+
toPath: string
57+
): string {
58+
if (path === fromPath) {
59+
return toPath;
60+
}
61+
62+
if (path.startsWith(`${fromPath}/`)) {
63+
return `${toPath}${path.slice(fromPath.length)}`;
64+
}
65+
66+
return path;
67+
}
68+
69+
export function rewriteOpenEditorPaths(
70+
paths: Iterable<string>,
71+
fromPath: string,
72+
toPath: string
73+
): string[] {
74+
return normalizeOpenEditorPaths(
75+
Array.from(paths).map((path) => rewriteDescendantEditorPath(path, fromPath, toPath))
76+
);
77+
}
78+
79+
export function normalizeWorkspaceEditorUiStatePatch(
80+
uiState: Partial<Pick<Workspace["uiState"], "openEditorPaths" | "activeEditorPath">>
81+
): { openEditorPaths?: string[]; activeEditorPath?: string | null } | null {
82+
const hasOpenEditorPaths = hasOwnProperty(uiState, "openEditorPaths");
83+
const hasActiveEditorPath = hasOwnProperty(uiState, "activeEditorPath");
84+
85+
if (!hasOpenEditorPaths && !hasActiveEditorPath) {
86+
return null;
87+
}
88+
89+
const next: { openEditorPaths?: string[]; activeEditorPath?: string | null } = {};
90+
91+
if (hasOpenEditorPaths) {
92+
const openEditorPaths = normalizeOpenEditorPaths(uiState.openEditorPaths);
93+
next.openEditorPaths = openEditorPaths;
94+
95+
if (hasActiveEditorPath) {
96+
const activeEditorPath = normalizeActiveEditorPath(uiState.activeEditorPath);
97+
next.activeEditorPath = activeEditorPath;
98+
99+
if (activeEditorPath && !openEditorPaths.includes(activeEditorPath)) {
100+
next.openEditorPaths = [...openEditorPaths, activeEditorPath];
101+
}
102+
}
103+
} else if (hasActiveEditorPath) {
104+
next.activeEditorPath = normalizeActiveEditorPath(uiState.activeEditorPath);
105+
}
106+
107+
return next;
108+
}
109+
110+
export function normalizeWorkspaceEditorUiState(
111+
uiState: Workspace["uiState"]
112+
): Workspace["uiState"] {
113+
const normalizedPatch = normalizeWorkspaceEditorUiStatePatch(uiState);
114+
if (!normalizedPatch) {
115+
return uiState;
116+
}
117+
118+
return {
119+
...uiState,
120+
...(hasOwnProperty(normalizedPatch, "openEditorPaths")
121+
? { openEditorPaths: normalizedPatch.openEditorPaths }
122+
: {}),
123+
...(hasOwnProperty(normalizedPatch, "activeEditorPath")
124+
? { activeEditorPath: normalizedPatch.activeEditorPath }
125+
: {}),
126+
};
127+
}
128+
129+
export function hydrateWorkspaceEditorState(
130+
store: Store,
131+
workspaceId: string,
132+
uiState?: Partial<Workspace["uiState"]> | null
133+
): void {
134+
if (!uiState) {
135+
return;
136+
}
137+
138+
const hasOpenEditorPaths = hasOwnProperty(uiState, "openEditorPaths");
139+
const hasActiveEditorPath = hasOwnProperty(uiState, "activeEditorPath");
140+
if (!hasOpenEditorPaths && !hasActiveEditorPath) {
141+
return;
142+
}
143+
144+
const normalizedPatch = normalizeWorkspaceEditorUiStatePatch(uiState);
145+
if (!normalizedPatch) {
146+
return;
147+
}
148+
149+
if (hasOpenEditorPaths) {
150+
store.set(openEditorPathsAtomFamily(workspaceId), normalizedPatch.openEditorPaths ?? []);
151+
}
152+
153+
if (hasActiveEditorPath) {
154+
store.set(activeFilePathAtomFamily(workspaceId), normalizedPatch.activeEditorPath ?? null);
155+
}
156+
}

packages/web/src/features/workspace/actions/open-editors-close.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import type { OpenFile } from "../atoms";
2+
import { mergeOpenEditorPaths, normalizeOpenEditorPaths } from "./open-editor-state";
23

3-
export function orderOpenEditorPaths(openFiles: Record<string, OpenFile>): string[] {
4-
return Object.keys(openFiles).sort();
4+
function isPathIterable(
5+
source: Record<string, OpenFile> | Iterable<string>
6+
): source is Iterable<string> {
7+
return typeof (source as Iterable<string>)[Symbol.iterator] === "function";
8+
}
9+
10+
export function orderOpenEditorPaths(
11+
source: Record<string, OpenFile> | Iterable<string>
12+
): string[] {
13+
const paths = isPathIterable(source) ? Array.from(source) : Object.keys(source);
14+
return normalizeOpenEditorPaths(paths).sort();
515
}
616

717
interface ResolveOpenEditorsCloseInput {
818
openFiles: Record<string, OpenFile>;
19+
openEditorPaths?: string[];
920
activeFilePath: string | null;
1021
pendingActiveFilePath?: string | null;
1122
targetPath?: string;
@@ -24,16 +35,19 @@ export function resolveOpenEditorsClose(
2435
): ResolveOpenEditorsCloseResult {
2536
const {
2637
openFiles,
38+
openEditorPaths = [],
2739
activeFilePath,
2840
pendingActiveFilePath = null,
2941
targetPath,
3042
closeAll = false,
3143
} = input;
32-
const orderedPaths = orderOpenEditorPaths(openFiles);
33-
const resolvedOrderedPaths =
34-
pendingActiveFilePath && !orderedPaths.includes(pendingActiveFilePath)
35-
? [...orderedPaths, pendingActiveFilePath].sort()
36-
: orderedPaths;
44+
const resolvedOrderedPaths = orderOpenEditorPaths(
45+
mergeOpenEditorPaths(
46+
openEditorPaths,
47+
Object.keys(openFiles),
48+
pendingActiveFilePath ? [pendingActiveFilePath] : undefined
49+
)
50+
);
3751

3852
if (closeAll) {
3953
return {

0 commit comments

Comments
 (0)