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 3010a89..61b1470 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -271,8 +271,15 @@ 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,
+ modifiedMs: 0,
+ };
});
tauriMocks.recordRecentFile.mockResolvedValue(undefined);
tauriMocks.readFile.mockImplementation(async (path: string) => {
@@ -2779,6 +2786,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 +2798,42 @@ 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 () => {
+ 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" },
+ });
+
+ 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 +3617,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..5557f9c 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 entry = await statFile(path);
+ const contents = await readFile(path, maxOpenFileKb * 1024);
+ 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,17 +1844,15 @@ 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);
setOpenFiles((current) =>
current.map((file) =>
file.path === fileToSave.path && file.contents === fileToSave.contents
- ? { ...file, dirty: false, modifiedMs }
+ ? { ...file, dirty: false, modifiedMs: savedEntry.modifiedMs }
: file,
),
);
+ await refreshFiles();
setStatus("Saved");
return true;
} catch (reason) {
@@ -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 ? (
+
+
+ {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;