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
115 changes: 115 additions & 0 deletions src/hooks/mcpBridge/v2/__tests__/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
writeTextFile: (path: string, content: string) => writeMock(path, content),
}));

const registerPendingSaveMock = vi.fn(() => 1);
const clearPendingSaveMock = vi.fn();
vi.mock("@/utils/pendingSaves", () => ({
registerPendingSave: (path: string, content: string) =>
registerPendingSaveMock(path, content),
clearPendingSave: (path: string, token?: number) =>
clearPendingSaveMock(path, token),
}));

import { respond } from "../../utils";
import {
handleWorkspaceOpen,
Expand Down Expand Up @@ -203,6 +212,9 @@ describe("vmark.workspace.save / save_as", () => {
beforeEach(() => {
vi.clearAllMocks();
resetStores();
writeMock.mockReset().mockResolvedValue(undefined);
registerPendingSaveMock.mockReset().mockReturnValue(1);
clearPendingSaveMock.mockReset();
});

it("save writes the doc content to its existing filePath", async () => {
Expand Down Expand Up @@ -271,6 +283,109 @@ describe("vmark.workspace.save / save_as", () => {
useDocumentStore.getState().documents["t-a"].filePath,
).toBe("/tmp/new.md");
});

it("save registers and clears pending save around writeTextFile to suppress the external-change dialog", async () => {
useTabStore.setState({
tabs: {
main: [
{
id: "t-ps",
filePath: "/tmp/notes.md",
title: "notes",
isPinned: false,
},
],
},
activeTabId: { main: "t-ps" },
untitledCounter: 0,
closedTabs: {},
});
useDocumentStore.getState().initDocument("t-ps", "hi", "/tmp/notes.md");
useDocumentStore.getState().setContent("t-ps", "updated");

await handleWorkspaceSave("req-ps", {});

expect(registerPendingSaveMock).toHaveBeenCalledWith("/tmp/notes.md", "updated");
expect(clearPendingSaveMock).toHaveBeenCalledWith("/tmp/notes.md", 1);
const registerOrder = registerPendingSaveMock.mock.invocationCallOrder[0];
const writeOrder = writeMock.mock.invocationCallOrder[0];
const clearOrder = clearPendingSaveMock.mock.invocationCallOrder[0];
expect(registerOrder).toBeLessThan(writeOrder);
expect(writeOrder).toBeLessThan(clearOrder);
});

it("save clears pending save even when writeTextFile rejects", async () => {
useTabStore.setState({
tabs: {
main: [
{
id: "t-ps-fail",
filePath: "/readonly/notes.md",
title: "notes",
isPinned: false,
},
],
},
activeTabId: { main: "t-ps-fail" },
untitledCounter: 0,
closedTabs: {},
});
useDocumentStore.getState().initDocument("t-ps-fail", "x", "/readonly/notes.md");
writeMock.mockRejectedValueOnce(new Error("EACCES"));

await handleWorkspaceSave("req-ps-fail", {});

expect(registerPendingSaveMock).toHaveBeenCalledWith("/readonly/notes.md", "x");
expect(clearPendingSaveMock).toHaveBeenCalledWith("/readonly/notes.md", 1);
});

it("save_as registers and clears pending save around writeTextFile to suppress the external-change dialog", async () => {
useTabStore.setState({
tabs: {
main: [{ id: "t-as", filePath: null, title: "u", isPinned: false }],
},
activeTabId: { main: "t-as" },
untitledCounter: 0,
closedTabs: {},
});
useDocumentStore.getState().initDocument("t-as", "hello", null);

await handleWorkspaceSaveAs("req-as", {
tabId: "t-as",
filePath: "/tmp/new.md",
});

expect(registerPendingSaveMock).toHaveBeenCalledWith("/tmp/new.md", "hello");
expect(clearPendingSaveMock).toHaveBeenCalledWith("/tmp/new.md", 1);
const registerOrder = registerPendingSaveMock.mock.invocationCallOrder[0];
const writeOrder = writeMock.mock.invocationCallOrder[0];
const clearOrder = clearPendingSaveMock.mock.invocationCallOrder[0];
expect(registerOrder).toBeLessThan(writeOrder);
expect(writeOrder).toBeLessThan(clearOrder);
});

it("save_as clears pending save even when writeTextFile rejects", async () => {
useTabStore.setState({
tabs: {
main: [{ id: "t-as-fail", filePath: null, title: "u", isPinned: false }],
},
activeTabId: { main: "t-as-fail" },
untitledCounter: 0,
closedTabs: {},
});
useDocumentStore.getState().initDocument("t-as-fail", "hello", null);
writeMock.mockRejectedValueOnce(new Error("EACCES"));

await expect(
handleWorkspaceSaveAs("req-as-fail", {
tabId: "t-as-fail",
filePath: "/readonly/new.md",
}),
).resolves.toBeUndefined();

expect(registerPendingSaveMock).toHaveBeenCalledWith("/readonly/new.md", "hello");
expect(clearPendingSaveMock).toHaveBeenCalledWith("/readonly/new.md", 1);
});
});

describe("vmark.workspace.focus_window", () => {
Expand Down
21 changes: 16 additions & 5 deletions src/hooks/mcpBridge/v2/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useTabStore } from "@/stores/tabStore";
import { useDocumentStore } from "@/stores/documentStore";
import { useRevisionStore } from "@/stores/documentStore";
import { getFileName } from "@/utils/paths";
import { registerPendingSave, clearPendingSave } from "@/utils/pendingSaves";
import { getCurrentWindowLabel } from "@/utils/workspaceStorage";
import { respond } from "../utils";
import { wrapHandler } from "./wrapHandler";
Expand Down Expand Up @@ -163,10 +164,15 @@ export async function handleWorkspaceSave(
await structuredError(id, resolved);
return;
}
await writeTextFile(resolved.filePath, resolved.content);
useDocumentStore
.getState()
.markSaved(resolved.tabId, resolved.content);
const saveToken = registerPendingSave(resolved.filePath, resolved.content);
try {
await writeTextFile(resolved.filePath, resolved.content);
useDocumentStore
.getState()
.markSaved(resolved.tabId, resolved.content);
} finally {
clearPendingSave(resolved.filePath, saveToken);
}
const revision = useRevisionStore.getState().getRevision(resolved.tabId);
await respond({
id,
Expand Down Expand Up @@ -233,7 +239,12 @@ export async function handleWorkspaceSaveAs(
});
return;
}
await writeTextFile(filePath, doc.content);
const saveToken = registerPendingSave(filePath, doc.content);
try {
await writeTextFile(filePath, doc.content);
} finally {
clearPendingSave(filePath, saveToken);
}
tabState.updateTabPath(tabId, filePath);
tabState.updateTabTitle(tabId, getFileName(filePath) || "Untitled");
docState.setFilePath(tabId, filePath);
Expand Down