mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
90ab87efcc
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>
148 lines
7.7 KiB
TypeScript
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);
|
|
});
|