Pressing Spawn while reading a session split that pane in half. It now opens the draft in its own tab, leaving the current tab untouched. Scoped to the explicit Spawn affordances (the header button and the sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the fast-forward key deliberately keep splitting: both act on the session in front of you, so a pane beside it is the point. Reusing an empty pane (#72) is unchanged — that path never split anything. Closes #100
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { emptyLayout, leaves, type Layout } from "./layout.js";
|
|
import { splitLeaf } from "./layout.js";
|
|
import { createMultiView, type MultiView } from "./multiview.js";
|
|
import type { ConnectionPool } from "./connections.js";
|
|
import type { SidebarHandle } from "./catalog.js";
|
|
|
|
function stubFetch(): void {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
const body = url.includes("/api/info")
|
|
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
|
: url.includes("/api/projects")
|
|
? []
|
|
: url.includes("/api/names")
|
|
? { names: ["allegro-cello"] }
|
|
: undefined;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
}
|
|
|
|
function fakePool(): ConnectionPool {
|
|
return {
|
|
setVisible() {},
|
|
setFocus() {},
|
|
} as unknown as ConnectionPool;
|
|
}
|
|
|
|
function fakeSidebar(): SidebarHandle {
|
|
return {
|
|
el: document.createElement("aside"),
|
|
setActive() {},
|
|
setHighlights() {},
|
|
dispose() {},
|
|
};
|
|
}
|
|
|
|
function makeMv(layout: Layout): MultiView {
|
|
return createMultiView({
|
|
pool: fakePool(),
|
|
layout,
|
|
sidebar: fakeSidebar(),
|
|
infoFor: () => ({ name: "sess", command: "bash" }),
|
|
onDisconnect: () => {},
|
|
renderWelcome: () => document.createElement("div"),
|
|
});
|
|
}
|
|
|
|
/** Drain the microtask queue so the draft form's async seed application lands. */
|
|
async function flush(): Promise<void> {
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
}
|
|
|
|
describe("multiview openDraft (#62)", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("turns an empty tab into a draft pane", async () => {
|
|
const layout = emptyLayout();
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
|
|
mv.openDraft();
|
|
await flush();
|
|
|
|
const ls = leaves(layout.tabs[0].root);
|
|
expect(ls).toHaveLength(1);
|
|
expect(ls[0].sessionId).toBeNull();
|
|
expect(ls[0].draft).not.toBeNull();
|
|
expect(mv.el.querySelector(".mv-draft")).toBeTruthy();
|
|
});
|
|
|
|
it("splits the focused session pane into a fresh draft", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
tab.focusedLeafId = first;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
mv.openDraft();
|
|
await flush();
|
|
|
|
const ls = leaves(tab.root);
|
|
expect(ls).toHaveLength(2);
|
|
expect(ls[0].sessionId).toBe("s1");
|
|
const draftLeaf = ls.find((l) => l.sessionId === null)!;
|
|
expect(draftLeaf.draft).not.toBeNull();
|
|
expect(tab.focusedLeafId).toBe(draftLeaf.id);
|
|
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
|
|
});
|
|
|
|
it("reuses the focused pane when it is already empty", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
const empty = splitLeaf(tab, first, null, "row", true);
|
|
tab.focusedLeafId = empty;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
mv.openDraft();
|
|
await flush();
|
|
|
|
const ls = leaves(tab.root);
|
|
expect(ls).toHaveLength(2);
|
|
const target = ls.find((l) => l.id === empty)!;
|
|
expect(target.draft).not.toBeNull();
|
|
expect(tab.focusedLeafId).toBe(empty);
|
|
});
|
|
|
|
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
|
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
|
|
tab.focusedLeafId = draftLeaf;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
await flush();
|
|
|
|
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
|
|
nameInput.value = "wip";
|
|
nameInput.dispatchEvent(new Event("input"));
|
|
await flush();
|
|
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
|
|
|
|
const formBefore = mv.el.querySelector(".mv-draft");
|
|
mv.closeSession("s1");
|
|
await flush();
|
|
|
|
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
|
|
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
|
});
|
|
|
|
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
const empty = splitLeaf(tab, first, null, "row", true);
|
|
tab.focusedLeafId = empty;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
// Let the mounted form's loadProjects() settle and auto-sync its picked
|
|
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
|
|
await flush();
|
|
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
|
|
|
|
mv.openDraft();
|
|
await flush();
|
|
|
|
expect(leaves(tab.root)).toHaveLength(2);
|
|
expect(tab.focusedLeafId).toBe(empty);
|
|
});
|
|
|
|
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
const empty = splitLeaf(tab, first, null, "row", true);
|
|
tab.focusedLeafId = empty;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
await flush();
|
|
|
|
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
|
await flush();
|
|
|
|
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
|
|
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
|
expect(leaf.draft?.command).toBe("claude");
|
|
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
|
|
expect(cwdRaw.value).toBe("/home/user/proj");
|
|
});
|
|
|
|
it("seeds a clone's cwd and command into the draft", async () => {
|
|
const layout = emptyLayout();
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
|
|
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
|
await flush();
|
|
|
|
const leaf = leaves(layout.tabs[0].root)[0];
|
|
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
|
expect(leaf.draft?.command).toBe("claude");
|
|
});
|
|
});
|
|
|
|
describe("multiview pane-drag swap (#73)", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
|
|
* drag handler does — modelled after the pointer sequence in runDrag()/
|
|
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
|
|
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
|
|
// The real drag-start gate requires the pointerdown target to sit inside
|
|
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
|
|
// no such element, so stand one in for the gate check itself.
|
|
const marker = document.createElement("div");
|
|
marker.className = "attach-header";
|
|
sourcePane.appendChild(marker);
|
|
|
|
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
|
|
left: 100,
|
|
top: 0,
|
|
width: 100,
|
|
height: 100,
|
|
right: 200,
|
|
bottom: 100,
|
|
x: 100,
|
|
y: 0,
|
|
toJSON() {},
|
|
} as DOMRect);
|
|
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
|
|
targetPane,
|
|
];
|
|
|
|
marker.dispatchEvent(
|
|
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
|
|
);
|
|
window.dispatchEvent(
|
|
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
|
|
);
|
|
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
|
|
await flush();
|
|
}
|
|
|
|
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const leaf1 = splitLeaf(tab, "", null, "row", true);
|
|
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
await flush();
|
|
|
|
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
|
|
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
|
|
|
|
nameInputOf(leaf1).value = "west";
|
|
nameInputOf(leaf1).dispatchEvent(new Event("input"));
|
|
nameInputOf(leaf2).value = "east";
|
|
nameInputOf(leaf2).dispatchEvent(new Event("input"));
|
|
await flush();
|
|
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
|
|
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
|
|
|
|
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
|
|
|
// The leaf data swaps (already correct pre-fix)...
|
|
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
|
|
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
|
|
// ...and the mounted forms must follow, not stay behind showing the old
|
|
// pane's values (#73).
|
|
expect(nameInputOf(leaf1).value).toBe("east");
|
|
expect(nameInputOf(leaf2).value).toBe("west");
|
|
});
|
|
});
|
|
|
|
describe("openDraft newTab (#100)", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("opens its own tab instead of splitting the focused session", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
tab.focusedLeafId = first;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
mv.openDraft(undefined, { newTab: true });
|
|
await flush();
|
|
|
|
expect(layout.tabs).toHaveLength(2);
|
|
// The tab you were reading is untouched: still one pane, still its session.
|
|
const original = leaves(layout.tabs[0].root);
|
|
expect(original).toHaveLength(1);
|
|
expect(original[0].sessionId).toBe("s1");
|
|
// The draft is alone in the new, now-active tab.
|
|
const fresh = leaves(layout.tabs[1].root);
|
|
expect(fresh).toHaveLength(1);
|
|
expect(fresh[0].sessionId).toBeNull();
|
|
expect(fresh[0].draft).not.toBeNull();
|
|
expect(layout.activeTabId).toBe(layout.tabs[1].id);
|
|
});
|
|
|
|
it("still splits when newTab is not asked for", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
mv.openDraft();
|
|
await flush();
|
|
|
|
expect(layout.tabs).toHaveLength(1);
|
|
expect(leaves(tab.root)).toHaveLength(2);
|
|
});
|
|
});
|