mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
a3f8acd4e0
Reconnect can replace browse connection ids before save completes, so close handlers that only matched sessionId skipped dirty editors and tore down their writer. Remap as soon as reconnect is detected and match owners by sftpTabId. Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
6.9 KiB
TypeScript
227 lines
6.9 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
|
|
import { createEditorTabSaveService } from "./editorTabSave.ts";
|
|
import {
|
|
isBrowseSessionInteractive,
|
|
shouldParkBrowseSessions,
|
|
takeBrowseSessionsForClose,
|
|
} from "./sftp/browseSessionLifecycle.ts";
|
|
|
|
const deferred = <T = void>() => {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
};
|
|
|
|
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
|
id: "edt_1",
|
|
kind: "editor",
|
|
sessionId: "conn_1",
|
|
sftpTabId: "pane_1",
|
|
hostId: "host_1",
|
|
remotePath: "/tmp/file.txt",
|
|
fileName: "file.txt",
|
|
languageId: "plaintext",
|
|
content: "v1",
|
|
baselineContent: "old",
|
|
wordWrap: false,
|
|
viewState: null,
|
|
savingState: "idle",
|
|
saveError: null,
|
|
...overrides,
|
|
});
|
|
|
|
test("editor tab save service joins duplicate saves for the same content", async () => {
|
|
const store = new EditorTabStore();
|
|
store._debugInsert(makeTab());
|
|
const pending = deferred();
|
|
const writes: string[] = [];
|
|
const service = createEditorTabSaveService({
|
|
store,
|
|
write: async (_sessionId, _hostId, _remotePath, content) => {
|
|
writes.push(content);
|
|
await pending.promise;
|
|
return "conn_1";
|
|
},
|
|
});
|
|
|
|
const first = service.saveTab("edt_1");
|
|
const second = service.saveTab("edt_1", "v1");
|
|
|
|
assert.deepEqual(writes, ["v1"]);
|
|
pending.resolve();
|
|
|
|
assert.equal(await first, true);
|
|
assert.equal(await second, true);
|
|
assert.deepEqual(writes, ["v1"]);
|
|
assert.equal(store.getTab("edt_1")?.baselineContent, "v1");
|
|
assert.equal(store.getTab("edt_1")?.savingState, "idle");
|
|
});
|
|
|
|
test("editor tab save service queues newer tab content after an in-flight save", async () => {
|
|
const store = new EditorTabStore();
|
|
store._debugInsert(makeTab());
|
|
const firstSave = deferred();
|
|
const secondSave = deferred();
|
|
const writes: string[] = [];
|
|
const service = createEditorTabSaveService({
|
|
store,
|
|
write: async (_sessionId, _hostId, _remotePath, content) => {
|
|
writes.push(content);
|
|
await (content === "v1" ? firstSave.promise : secondSave.promise);
|
|
return "conn_1";
|
|
},
|
|
});
|
|
|
|
const first = service.saveTab("edt_1");
|
|
store.updateContent("edt_1", "v2", null);
|
|
const second = service.saveTab("edt_1");
|
|
|
|
assert.deepEqual(writes, ["v1"]);
|
|
firstSave.resolve();
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
assert.deepEqual(writes, ["v1", "v2"]);
|
|
secondSave.resolve();
|
|
|
|
assert.equal(await first, true);
|
|
assert.equal(await second, true);
|
|
assert.equal(store.getTab("edt_1")?.baselineContent, "v2");
|
|
assert.equal(store.getTab("edt_1")?.content, "v2");
|
|
});
|
|
|
|
test("promoted side-panel editor saves while hidden and releases its session after close", async () => {
|
|
const store = new EditorTabStore();
|
|
const tabId = store.promoteFromModal({
|
|
sessionId: "conn_1",
|
|
sftpTabId: "pane_1",
|
|
hostId: "host_1",
|
|
remotePath: "/tmp/script.sh",
|
|
fileName: "script.sh",
|
|
languageId: "shell",
|
|
content: "echo changed",
|
|
baselineContent: "echo original",
|
|
wordWrap: false,
|
|
viewState: null,
|
|
});
|
|
const browseSessions = new Map([["conn_1", "sftp_1"]]);
|
|
const ownedSessionIds = new Set(["conn_1"]);
|
|
const ownedSftpTabIds = new Set(["pane_1"]);
|
|
const applyHiddenLifecycle = () => {
|
|
const interactive = isBrowseSessionInteractive({
|
|
surfaceVisible: false,
|
|
hasOwnedEditorTab: store.hasOwnedEditorForSftpOwner({
|
|
sessionIds: ownedSessionIds,
|
|
sftpTabIds: ownedSftpTabIds,
|
|
}),
|
|
});
|
|
if (shouldParkBrowseSessions({ interactive, browseParked: false })) {
|
|
takeBrowseSessionsForClose(browseSessions);
|
|
}
|
|
};
|
|
|
|
applyHiddenLifecycle();
|
|
assert.equal(browseSessions.get("conn_1"), "sftp_1");
|
|
|
|
const writes: string[] = [];
|
|
const service = createEditorTabSaveService({
|
|
store,
|
|
write: async (connectionId, _hostId, _remotePath, content) => {
|
|
assert.equal(browseSessions.has(connectionId), true);
|
|
writes.push(content);
|
|
return connectionId;
|
|
},
|
|
});
|
|
assert.equal(await service.saveTab(tabId), true);
|
|
assert.deepEqual(writes, ["echo changed"]);
|
|
|
|
store.close(tabId);
|
|
applyHiddenLifecycle();
|
|
assert.equal(browseSessions.size, 0);
|
|
});
|
|
|
|
test("hidden panel stays interactive while browse reconnects before session remap", () => {
|
|
const store = new EditorTabStore();
|
|
store.promoteFromModal({
|
|
sessionId: "conn_old",
|
|
sftpTabId: "pane_1",
|
|
hostId: "host_1",
|
|
remotePath: "/tmp/script.sh",
|
|
fileName: "script.sh",
|
|
languageId: "shell",
|
|
content: "echo changed",
|
|
baselineContent: "echo original",
|
|
wordWrap: false,
|
|
viewState: null,
|
|
});
|
|
const browseSessions = new Map([["conn_new", "sftp_1"]]);
|
|
|
|
const interactive = isBrowseSessionInteractive({
|
|
surfaceVisible: false,
|
|
hasOwnedEditorTab: store.hasOwnedEditorForSftpOwner({
|
|
sessionIds: new Set(["conn_new"]),
|
|
sftpTabIds: new Set(["pane_1"]),
|
|
}),
|
|
});
|
|
assert.equal(interactive, true);
|
|
assert.equal(
|
|
shouldParkBrowseSessions({ interactive, browseParked: false }),
|
|
false,
|
|
);
|
|
assert.equal(browseSessions.get("conn_new"), "sftp_1");
|
|
});
|
|
|
|
test("editor tab save remaps stale session ids returned by the SFTP writer", async () => {
|
|
const store = new EditorTabStore();
|
|
store._debugInsert(makeTab({ sessionId: "conn_old", sftpTabId: "pane_1" }));
|
|
const seenConnectionIds: string[] = [];
|
|
const service = createEditorTabSaveService({
|
|
store,
|
|
write: async (connectionId, _hostId, _remotePath, content, _encoding, sftpTabId) => {
|
|
seenConnectionIds.push(connectionId);
|
|
assert.equal(sftpTabId, "pane_1");
|
|
if (content === "next") {
|
|
assert.equal(connectionId, "conn_old");
|
|
store.remapSessionId("conn_old", "conn_new");
|
|
return "conn_new";
|
|
}
|
|
assert.equal(connectionId, "conn_new");
|
|
assert.equal(content, "next2");
|
|
return "conn_new";
|
|
},
|
|
});
|
|
|
|
assert.equal(await service.saveTab("edt_1", "next"), true);
|
|
assert.equal(store.getTab("edt_1")?.sessionId, "conn_new");
|
|
|
|
store.updateContent("edt_1", "next2", null);
|
|
assert.equal(await service.saveTab("edt_1"), true);
|
|
assert.deepEqual(seenConnectionIds, ["conn_old", "conn_new"]);
|
|
});
|
|
|
|
test("closing an SFTP pane prompts for dirty editors after reconnect id churn", async () => {
|
|
const store = new EditorTabStore();
|
|
store._debugInsert(makeTab({
|
|
sessionId: "conn_old",
|
|
sftpTabId: "pane_1",
|
|
content: "dirty",
|
|
baselineContent: "clean",
|
|
}));
|
|
let prompted = false;
|
|
const ok = await store.confirmCloseByOwner(
|
|
{ sessionId: "conn_new", sftpTabId: "pane_1" },
|
|
async () => {
|
|
prompted = true;
|
|
return "discard";
|
|
},
|
|
);
|
|
assert.equal(prompted, true);
|
|
assert.equal(ok, true);
|
|
assert.equal(store.getTabs().length, 0);
|
|
});
|