Skip to content

Commit 882a476

Browse files
committed
Add workspace refresh and file tree persistence
1 parent 51bd1fc commit 882a476

23 files changed

Lines changed: 1159 additions & 39 deletions

packages/core/src/domain/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface UiState {
3636
focusMode: boolean;
3737
activeSessionId?: string;
3838
paneLayout?: WorkspacePaneNode;
39+
fileTreeExpandedDirs?: string[];
3940
}
4041

4142
export interface WorkspaceLastViewedTarget {

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,81 @@ describe("File Commands", () => {
190190
reason: "file_content",
191191
});
192192
});
193+
194+
it("returns image version metadata from file.read", async () => {
195+
await writeFile(join(testDir, "logo.svg"), '<svg xmlns="http://www.w3.org/2000/svg"></svg>');
196+
197+
const result = await dispatch(
198+
{
199+
kind: "command",
200+
id: "file-read-image-1",
201+
op: "file.read",
202+
args: {
203+
workspaceId,
204+
path: "logo.svg",
205+
},
206+
},
207+
ctx
208+
);
209+
210+
expect(result.ok).toBe(true);
211+
expect(result.data).toMatchObject({
212+
kind: "image",
213+
mime: "image/svg+xml",
214+
url: `/api/file?workspaceId=${workspaceId}&path=logo.svg`,
215+
isTextBacked: true,
216+
version: expect.any(String),
217+
});
218+
});
219+
220+
it("changes image version metadata when contents change even if the file size stays the same", async () => {
221+
const imagePath = join(testDir, "logo.svg");
222+
await writeFile(
223+
imagePath,
224+
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>'
225+
);
226+
227+
const first = await dispatch(
228+
{
229+
kind: "command",
230+
id: "file-read-image-version-1",
231+
op: "file.read",
232+
args: {
233+
workspaceId,
234+
path: "logo.svg",
235+
},
236+
},
237+
ctx
238+
);
239+
240+
expect(first.ok).toBe(true);
241+
242+
await writeFile(
243+
imagePath,
244+
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="20" height="20"/></svg>'
245+
);
246+
247+
const second = await dispatch(
248+
{
249+
kind: "command",
250+
id: "file-read-image-version-2",
251+
op: "file.read",
252+
args: {
253+
workspaceId,
254+
path: "logo.svg",
255+
},
256+
},
257+
ctx
258+
);
259+
260+
expect(second.ok).toBe(true);
261+
expect((first.data as { kind: "image"; size: number; version: string }).kind).toBe("image");
262+
expect((second.data as { kind: "image"; size: number; version: string }).kind).toBe("image");
263+
expect((first.data as { kind: "image"; size: number; version: string }).size).toBe(
264+
(second.data as { kind: "image"; size: number; version: string }).size
265+
);
266+
expect((first.data as { kind: "image"; size: number; version: string }).version).not.toBe(
267+
(second.data as { kind: "image"; size: number; version: string }).version
268+
);
269+
});
193270
});

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,48 @@ describe("Workspace Commands", () => {
204204
],
205205
});
206206
});
207+
208+
it("persists fileTreeExpandedDirs into workspace ui state", async () => {
209+
const dir = join(tmpdir(), `workspace-expanded-dirs-test-${Date.now()}`);
210+
await mkdir(dir);
211+
212+
const openResult = await dispatch(
213+
{
214+
kind: "command",
215+
id: "open-workspace-expanded-dirs",
216+
op: "workspace.open",
217+
args: { path: dir },
218+
},
219+
ctx
220+
);
221+
222+
expect(openResult.ok).toBe(true);
223+
const workspaceId = (openResult.data as { id: string }).id;
224+
225+
const result = await dispatch(
226+
{
227+
kind: "command",
228+
id: "set-ui-state-expanded-dirs",
229+
op: "workspace.uiState.set",
230+
args: {
231+
workspaceId,
232+
uiState: {
233+
leftPanelWidth: 320,
234+
bottomPanelHeight: 210,
235+
focusMode: false,
236+
fileTreeExpandedDirs: ["packages", "packages/web"],
237+
},
238+
},
239+
},
240+
ctx
241+
);
242+
243+
expect(result.ok).toBe(true);
244+
expect(
245+
(result.data as { uiState: { fileTreeExpandedDirs?: string[] } }).uiState
246+
.fileTreeExpandedDirs
247+
).toEqual(["packages", "packages/web"]);
248+
});
207249
});
208250

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

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,29 @@ describe("WorkspaceRepo", () => {
243243
],
244244
});
245245
});
246+
247+
it("updates fileTreeExpandedDirs inside ui state", () => {
248+
repo.create({
249+
id: "ws-expanded",
250+
path: "/path/to/workspace",
251+
targetRuntime: "native",
252+
openedAt: Date.now(),
253+
lastActiveAt: Date.now(),
254+
uiState: { leftPanelWidth: 250, bottomPanelHeight: 150, focusMode: false },
255+
});
256+
257+
repo.updateUiState("ws-expanded", {
258+
leftPanelWidth: 250,
259+
bottomPanelHeight: 150,
260+
focusMode: false,
261+
fileTreeExpandedDirs: ["src", "src/components"],
262+
});
263+
264+
expect(repo.findById("ws-expanded")?.uiState.fileTreeExpandedDirs).toEqual([
265+
"src",
266+
"src/components",
267+
]);
268+
});
246269
});
247270

248271
describe("updateLastActive", () => {

packages/server/src/commands/workspace.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ registerCommand(
7272
bottomPanelHeight: z.number(),
7373
focusMode: z.boolean(),
7474
activeSessionId: z.string().optional(),
75+
fileTreeExpandedDirs: z.array(z.string()).optional(),
7576
paneLayout: z
7677
.object({
7778
id: z.string(),

packages/server/src/fs/file-io.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface FileReadImageResult {
2626
* text and the UI should offer an "edit as text" toggle.
2727
*/
2828
isTextBacked: boolean;
29+
/** Version marker for cache-busting and external-refresh detection. */
30+
version: string;
2931
}
3032

3133
export type FileReadResult = FileReadTextResult | FileReadImageResult;
@@ -118,7 +120,7 @@ export async function readFile(
118120

119121
const imageType = getImageTypeInfo(relPath);
120122
if (imageType) {
121-
const stats = await stat(abs);
123+
const bytes = await fsReadFile(abs);
122124
const params = new URLSearchParams({
123125
workspaceId,
124126
path: relPath,
@@ -127,8 +129,9 @@ export async function readFile(
127129
kind: "image",
128130
mime: imageType.mime,
129131
url: `/api/file?${params.toString()}`,
130-
size: stats.size,
132+
size: bytes.byteLength,
131133
isTextBacked: imageType.isTextBacked,
134+
version: createHash("sha256").update(bytes).digest("hex"),
132135
};
133136
}
134137

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "../atoms/workspaces";
1919
import { terminalPreferencesAtom } from "../features/terminal-panel/preferences";
2020
import {
21+
expandedDirsAtomFamily,
2122
fileTreeAtomFamily,
2223
fileTreeStaleAtomFamily,
2324
gitBranchListAtomFamily,
@@ -693,6 +694,7 @@ describe("AppProviders lifecycle recovery", () => {
693694
});
694695
store.set(fileTreeAtomFamily("ws-1"), new Map([[".", []]]));
695696
store.set(loadedDirsAtomFamily("ws-1"), new Set(["src"]));
697+
store.set(expandedDirsAtomFamily("ws-1"), new Set(["src"]));
696698
store.set(worktreeListAtomFamily("ws-1"), {
697699
items: [],
698700
loading: false,
@@ -738,6 +740,7 @@ describe("AppProviders lifecycle recovery", () => {
738740
expect(store.get(activeWorkspaceIdAtom)).toBeNull();
739741
expect(store.get(fileTreeAtomFamily("ws-1"))).toBeNull();
740742
expect(Array.from(store.get(loadedDirsAtomFamily("ws-1")))).toEqual([]);
743+
expect(store.get(expandedDirsAtomFamily("ws-1"))).toBeNull();
741744
expect(store.get(gitStateAtomFamily("ws-1"))).toBeNull();
742745
expect(store.get(gitBranchListAtomFamily("ws-1")).current).toBe("");
743746
expect(store.get(worktreeListAtomFamily("ws-1")).items).toEqual([]);

packages/web/src/app/providers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
} from "../features/terminal-panel/preferences";
5656
import {
5757
editorRefreshTokenAtomFamily,
58+
expandedDirsAtomFamily,
5859
fileTreeAtomFamily,
5960
fileTreeStaleAtomFamily,
6061
gitBranchListAtomFamily,
@@ -173,6 +174,7 @@ function resetServerProjectedState(store: Store): void {
173174
for (const workspaceId of workspaceIds) {
174175
store.set(fileTreeAtomFamily(workspaceId), null);
175176
store.set(loadedDirsAtomFamily(workspaceId), new Set());
177+
store.set(expandedDirsAtomFamily(workspaceId), null);
176178
store.set(gitStateAtomFamily(workspaceId), null);
177179
store.set(gitBranchListAtomFamily(workspaceId), {
178180
current: "",

packages/web/src/features/code-editor/actions/use-code-editor-actions.ts

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type FileReadImagePayload = {
2222
mime: string;
2323
url: string;
2424
size: number;
25+
version: string;
2526
isTextBacked: boolean;
2627
};
2728

@@ -120,6 +121,7 @@ export function useCodeEditorActions() {
120121
mime: data.mime,
121122
url: data.url,
122123
size: data.size,
124+
version: data.version,
123125
isTextBacked: data.isTextBacked,
124126
externalState: undefined,
125127
};
@@ -131,6 +133,15 @@ export function useCodeEditorActions() {
131133
[dispatch, setOpenFiles, workspaceId]
132134
);
133135

136+
const loadTextBackedImageContent = useCallback(async (url: string) => {
137+
const response = await fetch(url, { credentials: "include" });
138+
if (!response.ok) {
139+
throw new Error(`Failed to fetch text-backed image bytes: ${response.status}`);
140+
}
141+
142+
return response.text();
143+
}, []);
144+
134145
const handleSave = useCallback(async () => {
135146
if (!workspaceId || !currentFile || currentFile.kind !== "text" || isSaving) {
136147
return;
@@ -298,8 +309,93 @@ export function useCodeEditorActions() {
298309
continue;
299310
}
300311

312+
if (
313+
file.kind === "text" &&
314+
file.viewingTextBackedImageAsText === true &&
315+
nextData.kind === "image" &&
316+
nextData.isTextBacked
317+
) {
318+
if (file.isDirty) {
319+
setOpenFiles((prev) => {
320+
const existing = prev[path];
321+
if (!existing || existing.kind !== "text") {
322+
return prev;
323+
}
324+
325+
return {
326+
...prev,
327+
[path]: {
328+
...existing,
329+
externalState: "modified",
330+
},
331+
};
332+
});
333+
334+
if (activeFilePath === path) {
335+
setExternalStatus({
336+
path,
337+
status: "modified",
338+
});
339+
}
340+
continue;
341+
}
342+
343+
try {
344+
const content = await loadTextBackedImageContent(nextData.url);
345+
if (cancelled) {
346+
return;
347+
}
348+
349+
setOpenFiles((prev) => {
350+
const existing = prev[path];
351+
if (!existing || existing.kind !== "text") {
352+
return prev;
353+
}
354+
355+
return {
356+
...prev,
357+
[path]: {
358+
...existing,
359+
content,
360+
isDirty: false,
361+
externalState: undefined,
362+
viewingTextBackedImageAsText: true,
363+
},
364+
};
365+
});
366+
367+
if (activeFilePath === path) {
368+
setExternalStatus((current) => (current?.path === path ? null : current));
369+
}
370+
} catch (error) {
371+
console.error("Failed to refresh text-backed image bytes:", error);
372+
setOpenFiles((prev) => {
373+
const existing = prev[path];
374+
if (!existing || existing.kind !== "text") {
375+
return prev;
376+
}
377+
378+
return {
379+
...prev,
380+
[path]: {
381+
...existing,
382+
externalState: "modified",
383+
},
384+
};
385+
});
386+
387+
if (activeFilePath === path) {
388+
setExternalStatus({
389+
path,
390+
status: "modified",
391+
});
392+
}
393+
}
394+
continue;
395+
}
396+
301397
if (file.kind === "image" && nextData.kind === "image") {
302-
if (file.url === nextData.url && file.size === nextData.size) {
398+
if (file.version === nextData.version && file.size === nextData.size) {
303399
continue;
304400
}
305401

@@ -311,6 +407,7 @@ export function useCodeEditorActions() {
311407
mime: nextData.mime,
312408
url: nextData.url,
313409
size: nextData.size,
410+
version: nextData.version,
314411
isTextBacked: nextData.isTextBacked,
315412
externalState: undefined,
316413
},
@@ -337,7 +434,15 @@ export function useCodeEditorActions() {
337434
return () => {
338435
cancelled = true;
339436
};
340-
}, [activeFilePath, dispatch, editorRefreshToken, openFiles, setOpenFiles, workspaceId]);
437+
}, [
438+
activeFilePath,
439+
dispatch,
440+
editorRefreshToken,
441+
loadTextBackedImageContent,
442+
openFiles,
443+
setOpenFiles,
444+
workspaceId,
445+
]);
341446

342447
const handleClose = useCallback(() => {
343448
if (!workspaceId) {

0 commit comments

Comments
 (0)