Skip to content
Merged
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
19 changes: 18 additions & 1 deletion scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 48 additions & 3 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Comment thread
GordonBeeming marked this conversation as resolved.
});
tauriMocks.recordRecentFile.mockResolvedValue(undefined);
tauriMocks.readFile.mockImplementation(async (path: string) => {
Expand Down Expand Up @@ -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",
Expand All @@ -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(<App />);

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(<App />);

Expand Down Expand Up @@ -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();
});
});

Expand Down
48 changes: 28 additions & 20 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Comment thread
GordonBeeming marked this conversation as resolved.
Comment thread
GordonBeeming marked this conversation as resolved.

const refreshWorkspaceIndexStats = useCallback(async () => {
try {
setWorkspaceIndexStats(await getWorkspaceIndexStats());
Expand Down Expand Up @@ -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,
}),
);
Expand All @@ -1285,7 +1291,7 @@ export default function App() {
setStatus("Open failed");
}
},
[maxOpenFileKb, openFiles, singleFileMode, trackActiveFile],
[openFiles, readOpenFileFromDisk, singleFileMode, trackActiveFile],
);

useEffect(() => {
Expand Down Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -1429,7 +1435,7 @@ export default function App() {
};
}, [
files,
maxOpenFileKb,
readOpenFileFromDisk,
singleFileMode,
uiStateLoaded,
workspaceLoadFailed,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
),
Expand All @@ -1938,7 +1941,7 @@ export default function App() {
setStatus("Reload failed");
return false;
}
}, [refreshFiles]);
}, [readOpenFileFromDisk, refreshFiles]);

const requestReloadActiveFile = useCallback(() => {
if (!activeFile) {
Expand Down Expand Up @@ -4994,7 +4997,12 @@ export default function App() {
</div>
) : null}

{error ? <div className="toast">{error}</div> : null}
{error ? (
<div className="toast" role="alert">
<TriangleAlert size={16} aria-hidden="true" />
<span>{error}</span>
</div>
) : null}
</main>
);
}
Expand Down
14 changes: 12 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1060,18 +1060,28 @@ 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);
color: var(--text);
font-size: 13px;
}

.toast svg {
flex: 0 0 auto;
margin-top: 1px;
color: var(--danger);
}

.quick-open {
position: fixed;
inset: 0;
Expand Down
Loading