A preview was distinguished only by the chip and a dashed ring, both easy to miss, and the sidebar's hover reused the neutral surface — so a hovered row said nothing about what the content pane was showing. Adds a `--preview` token family on a hue of its own, deliberately not the accent: the accent means "this is the one you picked", which a preview is not. The previewed pane's titlebar, the chip, the throwaway tab, the previewed sidebar row and hover all carry it. Palettes whose accent is already violet (periwinkle, orchid) move the hue via `--preview-h`.
425 lines
13 KiB
TypeScript
425 lines
13 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: ["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() {},
|
|
setPreview() {},
|
|
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 any pending async work lands. */
|
|
async function flush(): Promise<void> {
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
}
|
|
|
|
describe("multiview empty panes", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("renders a placeholder, not a spawn form", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
|
tab.focusedLeafId = splitLeaf(tab, first, null, "row", true);
|
|
const mv = makeMv(layout);
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
await flush();
|
|
|
|
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
|
|
expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy();
|
|
expect(mv.el.querySelector(".spawn-form")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("multiview pane-drag swap", () => {
|
|
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), which the fake pool
|
|
// never mounts; 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("swaps the two panes' sessions when one is dropped on the other's centre", async () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
const leaf1 = splitLeaf(tab, "", "s1", "row", true);
|
|
const leaf2 = splitLeaf(tab, leaf1, "s2", "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;
|
|
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
|
|
|
expect(leaves(tab.root).find((l) => l.id === leaf1)?.sessionId).toBe("s2");
|
|
expect(leaves(tab.root).find((l) => l.id === leaf2)?.sessionId).toBe("s1");
|
|
expect(tab.focusedLeafId).toBe(leaf2);
|
|
});
|
|
});
|
|
|
|
describe("openSessionBeside 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.openSessionBeside("s2", { 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 new session is alone in the new, now-active tab.
|
|
const fresh = leaves(layout.tabs[1].root);
|
|
expect(fresh).toHaveLength(1);
|
|
expect(fresh[0].sessionId).toBe("s2");
|
|
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.openSessionBeside("s2");
|
|
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 an empty 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 spawned session 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.openSessionBeside("s2", { newTab: true });
|
|
await flush();
|
|
|
|
expect(layout.tabs).toHaveLength(1);
|
|
const ls = leaves(tab.root);
|
|
expect(ls).toHaveLength(1);
|
|
expect(ls[0].sessionId).toBe("s2");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|