Moving the sidebar highlight (keyboard or hover) now shows that session in the content pane without opening it: its own tab comes forward, or a throwaway tab carries it. The layout, the saved layout and the daemon's focus report are all left untouched, and the keyboard stays in the list. Contract in docs/PREVIEW.md.
219 lines
6.5 KiB
TypeScript
219 lines
6.5 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the
|
|
// content pane without changing the layout the operator built.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js";
|
|
import { serializeLayout } from "./persistence.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: [] }
|
|
: undefined;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
}
|
|
|
|
interface Spy {
|
|
pool: ConnectionPool;
|
|
visible: () => string[];
|
|
focusCalls: (string | null)[];
|
|
mountedWithFocus: (boolean | undefined)[];
|
|
}
|
|
|
|
function fakePool(): Spy {
|
|
let visible: string[] = [];
|
|
const focusCalls: (string | null)[] = [];
|
|
const mountedWithFocus: (boolean | undefined)[] = [];
|
|
const pool = {
|
|
setVisible(entries: { id: string }[], opts?: { focus?: boolean }) {
|
|
visible = entries.map((e) => e.id);
|
|
mountedWithFocus.push(opts?.focus);
|
|
},
|
|
setFocus(id: string | null) {
|
|
focusCalls.push(id);
|
|
},
|
|
} as unknown as ConnectionPool;
|
|
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
|
|
}
|
|
|
|
function fakeSidebar(): SidebarHandle {
|
|
return {
|
|
el: document.createElement("aside"),
|
|
setActive() {},
|
|
setHighlights() {},
|
|
focusList() {},
|
|
hasListFocus() {
|
|
return false;
|
|
},
|
|
dispose() {},
|
|
};
|
|
}
|
|
|
|
function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView {
|
|
const mv = createMultiView({
|
|
pool,
|
|
layout,
|
|
sidebar: fakeSidebar(),
|
|
infoFor: () => ({ name: "sess", command: "bash" }),
|
|
onDisconnect: () => {},
|
|
renderWelcome: () => document.createElement("div"),
|
|
onLayoutChange,
|
|
});
|
|
document.body.appendChild(mv.el);
|
|
mv.render();
|
|
return mv;
|
|
}
|
|
|
|
/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */
|
|
function twoTabs(): Layout {
|
|
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);
|
|
layout.activeTabId = second.id;
|
|
return layout;
|
|
}
|
|
|
|
describe("sidebar preview", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("brings a session's own tab forward, then puts the layout back", () => {
|
|
const layout = twoTabs();
|
|
const before = serializeLayout(layout);
|
|
const spy = fakePool();
|
|
const mv = makeMv(layout, spy.pool);
|
|
|
|
mv.previewSession("s1");
|
|
expect(spy.visible()).toEqual(["s1"]);
|
|
expect(mv.previewedSessionId()).toBe("s1");
|
|
|
|
mv.previewSession(null);
|
|
expect(spy.visible()).toEqual(["s2"]);
|
|
expect(mv.previewedSessionId()).toBeNull();
|
|
expect(serializeLayout(layout)).toEqual(before);
|
|
});
|
|
|
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
|
const layout = twoTabs();
|
|
const before = serializeLayout(layout);
|
|
const spy = fakePool();
|
|
const mv = makeMv(layout, spy.pool);
|
|
|
|
mv.previewSession("s9");
|
|
expect(spy.visible()).toEqual(["s9"]);
|
|
expect(layout.tabs).toHaveLength(3);
|
|
// The throwaway tab is not something the operator has open.
|
|
expect(mv.openSessions().has("s9")).toBe(false);
|
|
|
|
mv.previewSession(null);
|
|
expect(layout.tabs).toHaveLength(2);
|
|
expect(serializeLayout(layout)).toEqual(before);
|
|
});
|
|
|
|
it("stepping between previews leaves only the newest one on screen", () => {
|
|
const layout = twoTabs();
|
|
const before = serializeLayout(layout);
|
|
const spy = fakePool();
|
|
const mv = makeMv(layout, spy.pool);
|
|
|
|
mv.previewSession("s9");
|
|
mv.previewSession("s1");
|
|
mv.previewSession("s8");
|
|
expect(spy.visible()).toEqual(["s8"]);
|
|
expect(layout.tabs).toHaveLength(3);
|
|
|
|
mv.previewSession(null);
|
|
expect(serializeLayout(layout)).toEqual(before);
|
|
});
|
|
|
|
it("opening what is previewed keeps it: the throwaway tab becomes real", () => {
|
|
const layout = twoTabs();
|
|
const spy = fakePool();
|
|
const mv = makeMv(layout, spy.pool);
|
|
|
|
mv.previewSession("s9");
|
|
mv.openSession("s9");
|
|
|
|
expect(mv.previewedSessionId()).toBeNull();
|
|
expect(layout.tabs).toHaveLength(3);
|
|
expect(mv.openSessions().has("s9")).toBe(true);
|
|
expect(spy.visible()).toEqual(["s9"]);
|
|
// Nothing the operator had open was evicted to make room.
|
|
expect(mv.openSessions().has("s2")).toBe(true);
|
|
});
|
|
|
|
it("never persists, and saves again once the preview is settled", () => {
|
|
const layout = twoTabs();
|
|
const spy = fakePool();
|
|
const saves = vi.fn();
|
|
const mv = makeMv(layout, spy.pool, saves);
|
|
saves.mockClear();
|
|
|
|
mv.previewSession("s9");
|
|
expect(saves).not.toHaveBeenCalled();
|
|
|
|
mv.previewSession(null);
|
|
expect(saves).toHaveBeenCalled();
|
|
});
|
|
|
|
it("never reports a previewed pane as focused to the daemon", () => {
|
|
const layout = twoTabs();
|
|
const spy = fakePool();
|
|
const mv = makeMv(layout, spy.pool);
|
|
spy.focusCalls.length = 0;
|
|
|
|
mv.previewSession("s1");
|
|
mv.previewSession("s9");
|
|
expect(spy.focusCalls).toEqual([]);
|
|
|
|
mv.previewSession(null);
|
|
expect(spy.focusCalls).toEqual(["s2"]);
|
|
});
|
|
|
|
it("mounts a preview without taking the keyboard", () => {
|
|
const layout = twoTabs();
|
|
const spy = fakePool();
|
|
const mv = makeMv(layout, spy.pool);
|
|
spy.mountedWithFocus.length = 0;
|
|
|
|
mv.previewSession("s1");
|
|
expect(spy.mountedWithFocus).toEqual([false]);
|
|
|
|
mv.openSession("s1");
|
|
expect(spy.mountedWithFocus.at(-1)).toBe(true);
|
|
});
|
|
|
|
it("keeps the tab bar hidden while a lone tab is previewing", () => {
|
|
const layout = emptyLayout();
|
|
const tab = layout.tabs[0];
|
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
|
const spy = fakePool();
|
|
const mv = makeMv(layout, spy.pool);
|
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
|
|
|
mv.previewSession("s9");
|
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
|
expect(mv.el.classList.contains("mv-previewing")).toBe(true);
|
|
|
|
mv.previewSession(null);
|
|
expect(mv.el.classList.contains("mv-previewing")).toBe(false);
|
|
});
|
|
});
|