The bar has nothing to switch between with one tab, so it is pure chrome. It reappears as soon as a second tab exists; spawn already opens its own tab, so the "+" button stays reachable.
561 lines
17 KiB
TypeScript
561 lines
17 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { addTab, 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() {},
|
|
focusList() {},
|
|
hasListFocus() {
|
|
return false;
|
|
},
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("multiview closeSession tab cleanup (#137)", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("closes the tab when its last session goes away", () => {
|
|
const layout = emptyLayout();
|
|
const first = layout.tabs[0];
|
|
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
|
const second = addTab(layout);
|
|
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
mv.closeSession("s2");
|
|
|
|
expect(layout.tabs).toHaveLength(1);
|
|
expect(layout.tabs[0].id).toBe(first.id);
|
|
expect(layout.activeTabId).toBe(first.id);
|
|
});
|
|
|
|
it("closes a background tab without stealing focus from the active one", () => {
|
|
const layout = emptyLayout();
|
|
const background = layout.tabs[0];
|
|
background.focusedLeafId = splitLeaf(background, "", "s1", "row", true);
|
|
const active = addTab(layout);
|
|
active.focusedLeafId = splitLeaf(active, "", "s2", "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
mv.closeSession("s1");
|
|
|
|
expect(layout.tabs).toHaveLength(1);
|
|
expect(layout.tabs[0].id).toBe(active.id);
|
|
expect(layout.activeTabId).toBe(active.id);
|
|
});
|
|
|
|
it("keeps a tab that still holds another pane", () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
|
|
addTab(layout);
|
|
layout.activeTabId = tab.id;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
mv.closeSession("s2");
|
|
|
|
expect(layout.tabs).toHaveLength(2);
|
|
expect(leaves(tab.root)).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps a tab holding a draft pane", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
|
splitLeaf(tab, s1, null, "row", true);
|
|
addTab(layout);
|
|
layout.activeTabId = tab.id;
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
await flush();
|
|
|
|
mv.closeSession("s1");
|
|
|
|
expect(layout.tabs).toHaveLength(2);
|
|
expect(leaves(tab.root)).toHaveLength(1);
|
|
});
|
|
|
|
it("never closes the only tab", () => {
|
|
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.closeSession("s1");
|
|
|
|
expect(layout.tabs).toHaveLength(1);
|
|
expect(layout.tabs[0].root).toBeNull();
|
|
expect(layout.activeTabId).toBe(tab.id);
|
|
});
|
|
});
|
|
|
|
describe("multiview mobile single pane", () => {
|
|
/** Force the mobile media query on, whatever happy-dom's viewport says. */
|
|
function forceMobile(): void {
|
|
vi.spyOn(window, "matchMedia").mockImplementation(
|
|
(query: string) =>
|
|
({
|
|
matches: query.includes("859"),
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
addListener() {},
|
|
removeListener() {},
|
|
dispatchEvent: () => false,
|
|
}) as unknown as MediaQueryList,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
forceMobile();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("replaces the focused pane instead of splitting beside it", () => {
|
|
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.openSessionBeside("s2");
|
|
|
|
const ls = leaves(tab.root);
|
|
expect(ls).toHaveLength(1);
|
|
expect(ls[0].sessionId).toBe("s2");
|
|
expect(layout.tabs).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps a spawn draft in the current pane rather than a new tab", 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(undefined, { newTab: true });
|
|
await flush();
|
|
|
|
expect(layout.tabs).toHaveLength(1);
|
|
const ls = leaves(tab.root);
|
|
expect(ls).toHaveLength(1);
|
|
expect(ls[0].sessionId).toBeNull();
|
|
expect(ls[0].draft).not.toBeNull();
|
|
});
|
|
|
|
it("renders one pane and no pane pills for a multi-leaf tab", () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1);
|
|
expect(mv.el.querySelector(".mv-panebar")).toBeNull();
|
|
expect(mv.el.querySelector(".mv-pill")).toBeNull();
|
|
});
|
|
|
|
it("collapses the tab bar even with several tabs", () => {
|
|
const layout = emptyLayout();
|
|
const first = layout.tabs[0];
|
|
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
|
const second = addTab(layout);
|
|
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("multiview solo tab bar", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("hides the tab bar while only one tab exists", () => {
|
|
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();
|
|
|
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
|
});
|
|
|
|
it("shows the tab bar again once a second tab appears", () => {
|
|
const layout = emptyLayout();
|
|
const first = layout.tabs[0];
|
|
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
|
const second = addTab(layout);
|
|
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
|
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
|
|
});
|
|
});
|