Files
Netcatty/application/state/editorWindowClient.test.ts
T
陈大猫 90ab87efcc feat(editor): detached multi-tab window with acknowledged content handoff (#3606)
Add a detached multi-tab editor window with acknowledged content ownership, focused Monaco shortcuts, and shared close/save confirmation. Preserve source contents until handoff acknowledgement and block edits or incoming transfers during ownership changes.

Continuation of #3439. Original implementation by pplulee.

Co-authored-by: pplulee <hi@pplulee.me>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:11:23 +08:00

148 lines
7.7 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
const snapshot = {
editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file", fileName: "file", languageId: "plaintext",
content: "v2", baselineContent: "v1", wordWrap: false, viewState: null,
};
for (const latestContent of ["v3", "v2", "v1"]) {
test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let finish!: (result: { ok: boolean }) => void;
const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; });
const reports: boolean[] = [];
t.mock.method(netcattyBridge, "get", () => ({
saveEditorWindowTab: (request: { content: string }) => {
assert.equal(request.content, "v2");
return pending;
},
reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty),
}));
const saving = saveDetachedEditorTab(snapshot.editorId);
editorTabStore.updateContent(snapshot.editorId, latestContent, null);
finish({ ok: true });
assert.deepEqual(await saving, { ok: true });
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent);
assert.deepEqual(reports, [latestContent !== "v2"]);
});
}
test("source save receipt preserves dirty until the detached renderer reports it", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot, "window");
t.after(() => {
editorTabStore.close(snapshot.editorId);
releaseEditorTabSaveCoordinator(snapshot.editorId);
});
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
let onDirty!: (payload: { editorId: string; dirty: boolean }) => void;
const receipts: EditorWindowSaveResult[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; },
onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; },
reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result),
}));
t.after(installEditorWindowSourceListeners());
const saving = onSave({ ...snapshot, requestId: "request" });
onDirty({ editorId: snapshot.editorId, dirty: true });
finish();
await saving;
assert.equal(receipts[0]?.ok, true);
assert.equal(editorTabStore.isDirty(snapshot.editorId), true);
onDirty({ editorId: snapshot.editorId, dirty: false });
assert.equal(editorTabStore.isDirty(snapshot.editorId), false);
});
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`popout retains source contents until the receiver ${outcome}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; });
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt }));
const opening = popOutEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1");
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted" });
assert.equal(await opening, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab");
assert.equal(tab.content, outcome === "accepted" ? "" : "v2");
assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
}
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed");
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean; error?: string }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; });
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({
dockEditorWindowTab: (sent: typeof snapshot) => {
calls++;
assert.equal(sent.content, "v2");
return receipt;
},
}));
const docking = dockDetachedEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer");
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 1);
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted", error: "Source unavailable" });
assert.equal((await docking).success, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction");
if (outcome !== "accepted") {
assert.equal(tab.saveError, "previous save failed");
editorTabStore.updateContent(snapshot.editorId, "v3", null);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3");
}
});
}
test("dock does not take over an in-progress save", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "saving");
t.after(() => editorTabStore.close(snapshot.editorId));
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } }));
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 0);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving");
});
test("a closing destination rejects a popout without releasing source content", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) }));
assert.equal(await popOutEditorTab(snapshot.editorId), false);
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, "tab");
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});