From 4e327d74b47d309fa4546eb78ca3face9cc6c7d5 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 24 Jun 2026 13:51:21 +1000 Subject: [PATCH 1/2] Fix stale save metadata handling Co-authored-by: Codex Co-authored-by: GitButler --- src/App.test.tsx | 39 ++++++++++++++++++++++++++++++++++++--- src/App.tsx | 48 ++++++++++++++++++++++++++++-------------------- src/styles.css | 14 ++++++++++++-- 3 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 3010a89..380d4c0 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -271,8 +271,14 @@ describe("App shell interactions", () => { ); tauriMocks.statFile.mockImplementation(async (path: string) => { const entry = files.find((candidate) => candidate.path === path); - if (!entry) throw new Error(`missing ${path}`); - return entry; + if (entry) return entry; + return { + path, + name: path.split("/").at(-1) ?? path, + isDir: false, + depth: path.split("/").length - 1, + size: 0, + }; }); tauriMocks.recordRecentFile.mockResolvedValue(undefined); tauriMocks.readFile.mockImplementation(async (path: string) => { @@ -2779,6 +2785,9 @@ describe("App shell interactions", () => { fireEvent.click(screen.getByTitle("Save")); await screen.findByText("Error: file changed on disk since it was opened"); + expect(screen.getByRole("alert")).toHaveTextContent( + "Error: file changed on disk since it was opened", + ); expect(tauriMocks.writeFile).toHaveBeenCalledWith( "README.md", "changed readme", @@ -2788,6 +2797,31 @@ describe("App shell interactions", () => { expect(screen.getByText("Save failed")).toBeInTheDocument(); }); + it("saves with fresh metadata after opening a file from a stale tree entry", async () => { + tauriMocks.statFile.mockImplementation(async (path: string) => { + const entry = files.find((candidate) => candidate.path === path); + if (!entry) throw new Error(`missing ${path}`); + return path === "README.md" ? { ...entry, modifiedMs: 303 } : entry; + }); + render(); + + fireEvent.click(await treeButton("README.md")); + await findTab("README.md"); + fireEvent.change(await screen.findByLabelText("Editor README.md"), { + target: { value: "changed readme" }, + }); + + fireEvent.click(screen.getByTitle("Save")); + + await waitFor(() => + expect(tauriMocks.writeFile).toHaveBeenCalledWith( + "README.md", + "changed readme", + 303, + ), + ); + }); + it("reloads a clean active file from disk", async () => { render(); @@ -3571,7 +3605,6 @@ describe("App shell interactions", () => { await screen.findByText("Error: permission denied"); expect(screen.getByRole("alertdialog", { name: "Delete file?" })).toBeInTheDocument(); - expect(screen.getByText("Delete failed")).toBeInTheDocument(); }); }); diff --git a/src/App.tsx b/src/App.tsx index bd78056..1acb600 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1075,6 +1075,12 @@ export default function App() { } }, [refreshFiles]); + const readOpenFileFromDisk = useCallback(async (path: string) => { + const contents = await readFile(path, maxOpenFileKb * 1024); + const entry = await statFile(path); + return { contents, modifiedMs: entry.modifiedMs }; + }, [maxOpenFileKb]); + const refreshWorkspaceIndexStats = useCallback(async () => { try { setWorkspaceIndexStats(await getWorkspaceIndexStats()); @@ -1262,13 +1268,13 @@ export default function App() { } try { - const contents = await readFile(entry.path, maxOpenFileKb * 1024); + const diskFile = await readOpenFileFromDisk(entry.path); setOpenFiles((current) => addPreviewTab(current, { path: entry.path, - contents, + contents: diskFile.contents, dirty: false, - modifiedMs: entry.modifiedMs, + modifiedMs: diskFile.modifiedMs, pinned, }), ); @@ -1285,7 +1291,7 @@ export default function App() { setStatus("Open failed"); } }, - [maxOpenFileKb, openFiles, singleFileMode, trackActiveFile], + [openFiles, readOpenFileFromDisk, singleFileMode, trackActiveFile], ); useEffect(() => { @@ -1355,14 +1361,14 @@ export default function App() { let disposed = false; Promise.all( restorePaths.map(async (path) => { - const entry = entriesByPath.get(path)!; try { + const diskFile = await readOpenFileFromDisk(path); return { tab: { path, - contents: await readFile(path, maxOpenFileKb * 1024), + contents: diskFile.contents, dirty: false, - modifiedMs: entry.modifiedMs, + modifiedMs: diskFile.modifiedMs, pinned: true, }, }; @@ -1429,7 +1435,7 @@ export default function App() { }; }, [ files, - maxOpenFileKb, + readOpenFileFromDisk, singleFileMode, uiStateLoaded, workspaceLoadFailed, @@ -1838,14 +1844,12 @@ export default function App() { setStatus(`Saving ${fileToSave.path}`); try { await writeFile(fileToSave.path, fileToSave.contents, fileToSave.modifiedMs); - const refreshedEntries = await refreshFiles(); - const modifiedMs = refreshedEntries.find( - (entry) => entry.path === fileToSave.path, - )?.modifiedMs; + const savedEntry = await statFile(fileToSave.path); + await refreshFiles(); setOpenFiles((current) => current.map((file) => file.path === fileToSave.path && file.contents === fileToSave.contents - ? { ...file, dirty: false, modifiedMs } + ? { ...file, dirty: false, modifiedMs: savedEntry.modifiedMs } : file, ), ); @@ -1913,17 +1917,16 @@ export default function App() { setError(undefined); setStatus(`Reloading ${path}`); try { - const contents = await readFile(path, maxOpenFileKb * 1024); - const refreshedEntries = await refreshFiles(); - const modifiedMs = refreshedEntries.find((entry) => entry.path === path)?.modifiedMs; + const diskFile = await readOpenFileFromDisk(path); + await refreshFiles(); setOpenFiles((current) => current.map((file) => file.path === path ? { ...file, - contents, + contents: diskFile.contents, dirty: false, - modifiedMs, + modifiedMs: diskFile.modifiedMs, } : file, ), @@ -1938,7 +1941,7 @@ export default function App() { setStatus("Reload failed"); return false; } - }, [refreshFiles]); + }, [readOpenFileFromDisk, refreshFiles]); const requestReloadActiveFile = useCallback(() => { if (!activeFile) { @@ -4994,7 +4997,12 @@ export default function App() { ) : null} - {error ?
{error}
: null} + {error ? ( +
+
+ ) : null} ); } diff --git a/src/styles.css b/src/styles.css index c19a28e..5751b8d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1060,11 +1060,15 @@ button { .toast { position: fixed; + top: 14px; right: 16px; - bottom: 38px; + z-index: 50; + display: flex; + align-items: flex-start; + gap: 8px; max-width: min(520px, calc(100vw - 32px)); padding: 10px 12px; - border: 1px solid var(--accent); + border: 1px solid var(--danger); border-radius: 6px; background: var(--panel); box-shadow: var(--shadow); @@ -1072,6 +1076,12 @@ button { font-size: 13px; } +.toast svg { + flex: 0 0 auto; + margin-top: 1px; + color: var(--danger); +} + .quick-open { position: fixed; inset: 0; From 0138cce501ce5563825642d77c471f7bbbef5542 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 24 Jun 2026 16:30:32 +1000 Subject: [PATCH 2/2] Address stale metadata PR feedback Co-authored-by: Codex Co-authored-by: GitButler --- scripts/smoke-test.mjs | 19 ++++++++++++++++++- src/App.test.tsx | 12 ++++++++++++ src/App.tsx | 4 ++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index cee6904..a6437e8 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -60,6 +60,21 @@ const fileContents = new Map([ ["package.json", "{\n \"name\": \"ide-smoke\"\n}\n"], ]); +const indexedOnlyFileMetadata = new Map([ + [ + "deep/Nested.ts", + { + path: "deep/Nested.ts", + name: "Nested.ts", + parent: "deep", + isDir: false, + depth: 1, + size: 42, + modifiedMs: 5, + }, + ], +]); + function findBrowserExecutable() { const candidates = [ process.env.IDE_SMOKE_BROWSER, @@ -143,7 +158,9 @@ async function fulfillApi(route) { if (url.pathname === "/api/file-metadata") { const filePath = url.searchParams.get("path") ?? ""; - const entry = files.find((candidate) => candidate.path === filePath); + const entry = + files.find((candidate) => candidate.path === filePath) ?? + indexedOnlyFileMetadata.get(filePath); if (entry) { await route.fulfill(json(entry)); } else { diff --git a/src/App.test.tsx b/src/App.test.tsx index 380d4c0..61b1470 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -278,6 +278,7 @@ describe("App shell interactions", () => { isDir: false, depth: path.split("/").length - 1, size: 0, + modifiedMs: 0, }; }); tauriMocks.recordRecentFile.mockResolvedValue(undefined); @@ -2798,15 +2799,26 @@ describe("App shell interactions", () => { }); it("saves with fresh metadata after opening a file from a stale tree entry", async () => { + const openOrder: string[] = []; tauriMocks.statFile.mockImplementation(async (path: string) => { const entry = files.find((candidate) => candidate.path === path); if (!entry) throw new Error(`missing ${path}`); + if (path === "README.md") openOrder.push("stat"); return path === "README.md" ? { ...entry, modifiedMs: 303 } : entry; }); + tauriMocks.readFile.mockImplementation(async (path: string) => { + if (path === "README.md") { + openOrder.push("read"); + return "readme"; + } + if (path === "src/App.tsx") return "export function App() {}"; + return ""; + }); render(); fireEvent.click(await treeButton("README.md")); await findTab("README.md"); + expect(openOrder).toEqual(["stat", "read"]); fireEvent.change(await screen.findByLabelText("Editor README.md"), { target: { value: "changed readme" }, }); diff --git a/src/App.tsx b/src/App.tsx index 1acb600..5557f9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1076,8 +1076,8 @@ export default function App() { }, [refreshFiles]); const readOpenFileFromDisk = useCallback(async (path: string) => { - const contents = await readFile(path, maxOpenFileKb * 1024); const entry = await statFile(path); + const contents = await readFile(path, maxOpenFileKb * 1024); return { contents, modifiedMs: entry.modifiedMs }; }, [maxOpenFileKb]); @@ -1845,7 +1845,6 @@ export default function App() { try { await writeFile(fileToSave.path, fileToSave.contents, fileToSave.modifiedMs); const savedEntry = await statFile(fileToSave.path); - await refreshFiles(); setOpenFiles((current) => current.map((file) => file.path === fileToSave.path && file.contents === fileToSave.contents @@ -1853,6 +1852,7 @@ export default function App() { : file, ), ); + await refreshFiles(); setStatus("Saved"); return true; } catch (reason) {