feat(maestro): preview the highlighted session in the web pane
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.
This commit is contained in:
@@ -72,6 +72,10 @@ before changing anything that creates or removes one.
|
||||
sidebars — line layout, activity colours, duration format, state precedence.
|
||||
Read it before changing sidebar rendering.
|
||||
|
||||
`docs/PREVIEW.md` is the contract for what the content pane shows while the
|
||||
operator moves the sidebar highlight — what a preview may touch, what ends it,
|
||||
what commits it. Read it before changing sidebar-driven navigation.
|
||||
|
||||
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
||||
or is upgraded under them — the build stamp on `/api/info`, the status
|
||||
indicator's states, the blocking overlay, grace and poll timings. Read it
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# PREVIEW.md — the sidebar preview contract
|
||||
|
||||
One rule for what the content pane shows while the operator is only moving
|
||||
around the sidebar, so the web client and the TUI behave the same. Read this
|
||||
before changing either. If the behaviour needs to change, change it here first.
|
||||
|
||||
## The rule
|
||||
|
||||
Highlighting a session in the sidebar shows that session in the content pane,
|
||||
as if it were focused, **without focusing it and without changing the layout**.
|
||||
Moving the highlight elsewhere puts back exactly what was on screen before.
|
||||
|
||||
A preview is a view, not a state. Nothing about it is durable:
|
||||
|
||||
- The layout is byte-for-byte identical before the preview and after it ends.
|
||||
- Nothing is persisted while a preview is up (the web client's layout is saved
|
||||
to `localStorage`; a preview never reaches it).
|
||||
- The daemon is never told the previewed pane is focused, so a preview never
|
||||
clears a session's `AwaitingInput` glow (`docs/SIDEBAR.md`).
|
||||
- The keyboard stays in the sidebar: the previewed terminal is on screen but
|
||||
does not take focus, so the next `j`/`k` still walks the list.
|
||||
|
||||
## Where the preview is shown
|
||||
|
||||
| The previewed session is… | What happens |
|
||||
|---|---|
|
||||
| Already in a pane, in the active tab | That pane comes forward (its tab's focus moves to it). |
|
||||
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
|
||||
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
|
||||
|
||||
The throwaway tab never counts as an open session: the tab bar does not appear
|
||||
because of it, the mobile shell does not switch away from the session list for
|
||||
it, and the sidebar does not mark the session as open.
|
||||
|
||||
## What ends a preview
|
||||
|
||||
- The highlight moves to another session — that session is previewed instead.
|
||||
- The highlight leaves every session (a folder row, or the list losing the
|
||||
keyboard, or the pointer leaving the list): the previous view comes back.
|
||||
- Any real layout change (split, close, new tab, tab switch, drag): the preview
|
||||
is discarded first, so a throwaway tab is never baked into the arrangement.
|
||||
|
||||
## What commits a preview
|
||||
|
||||
Choosing the previewed session — clicking or pressing Enter on its row,
|
||||
clicking into its pane, or handing the keyboard to the pane (web: `Tab` from
|
||||
the list; TUI: the attach binding) — keeps exactly what is on screen. A
|
||||
throwaway tab becomes a real one; a focus move inside an existing tab stands.
|
||||
Nothing moves a second time on commit.
|
||||
|
||||
## Timing
|
||||
|
||||
A preview follows the highlight closely enough to be usable as a way to look
|
||||
around, and lags a pointer enough not to flash rows crossed on the way
|
||||
somewhere else.
|
||||
|
||||
| Trigger | Delay |
|
||||
|---|---|
|
||||
| Keyboard step (`j`/`k`, arrows) | 60 ms |
|
||||
| Pointer hover (web, mouse only — never touch) | 150 ms |
|
||||
| Clearing (highlight left every session) | same delay as the trigger that set it |
|
||||
|
||||
Touch has no hover, and a tap already opens a session, so touch never previews.
|
||||
|
||||
## Cost
|
||||
|
||||
A previewed session that no pane holds needs a live attach. Both clients keep
|
||||
it connected after the preview ends — the web client parks it in the connection
|
||||
pool's LRU, the TUI keeps its emulator until it falls out of the preview
|
||||
budget — so stepping back onto a row is instant rather than re-fetching
|
||||
scrollback.
|
||||
+12
-5
@@ -32,8 +32,10 @@ export interface AttachConnection {
|
||||
readonly sessionId: Uuid;
|
||||
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
||||
readonly el: HTMLElement;
|
||||
/** Becomes the visible view: fit to the pane, focus, sync size. */
|
||||
onMount(): void;
|
||||
/** Becomes the visible view: fit to the pane, focus, sync size.
|
||||
* `focus: false` mounts without taking the keyboard — what a sidebar
|
||||
* preview needs, so j/k keeps working while the pane is on screen. */
|
||||
onMount(opts?: { focus?: boolean }): void;
|
||||
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
||||
onUnmount(): void;
|
||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||
@@ -309,6 +311,10 @@ export function createAttachConnection(
|
||||
}
|
||||
|
||||
let mounted = false;
|
||||
// A previewed pane is visible but must not steal the keyboard from the
|
||||
// sidebar, so mounting can opt out of the focus grab (and out of the one
|
||||
// the socket does when it finishes connecting).
|
||||
let focusOnMount = true;
|
||||
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
||||
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
||||
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
||||
@@ -387,7 +393,7 @@ export function createAttachConnection(
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setState("connected", "open");
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (mounted) term.focus();
|
||||
if (mounted && focusOnMount) term.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
@@ -472,14 +478,15 @@ export function createAttachConnection(
|
||||
return {
|
||||
sessionId,
|
||||
el,
|
||||
onMount() {
|
||||
onMount(mountOpts?: { focus?: boolean }) {
|
||||
mounted = true;
|
||||
focusOnMount = mountOpts?.focus !== false;
|
||||
acquireWebgl();
|
||||
// Defer a tick so the element has its content-pane dimensions before fit.
|
||||
requestAnimationFrame(() => {
|
||||
tryFit();
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
if (ws.readyState === WebSocket.OPEN) term.focus();
|
||||
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
|
||||
});
|
||||
},
|
||||
onUnmount() {
|
||||
|
||||
@@ -364,6 +364,11 @@ export interface SidebarOptions {
|
||||
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
||||
* design kit, which render the sidebar without a daemon. */
|
||||
link?: DaemonLink;
|
||||
/** The highlight moved onto a session, or off every session (`null`): the
|
||||
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
||||
* `source` is there so a mouse sweep can be debounced differently from a
|
||||
* keyboard step. */
|
||||
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
@@ -528,6 +533,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
||||
};
|
||||
|
||||
const sessionOf = (key: string | null): string | null =>
|
||||
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
||||
|
||||
const moveNav = (delta: number) => {
|
||||
const rows = navRows();
|
||||
if (rows.length === 0) return;
|
||||
@@ -539,6 +547,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
navKey = next.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
opts.onPreview?.(sessionOf(navKey), "key");
|
||||
};
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||
@@ -563,9 +572,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
applyNav();
|
||||
};
|
||||
|
||||
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||
// out: there is no hover there, and a tap already opens the session.
|
||||
let hovered: string | null = null;
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
hovered = id;
|
||||
opts.onPreview?.(id, "hover");
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||
setHovered(row?.dataset.sessionId ?? null);
|
||||
});
|
||||
treeHost.addEventListener("pointerleave", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setHovered(null);
|
||||
});
|
||||
|
||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
||||
// keyboard stays here while j/k runs.
|
||||
treeHost.tabIndex = -1;
|
||||
// The keyboard walk's preview belongs to the list having the keyboard: once
|
||||
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
|
||||
treeHost.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
// The inline rename field lives inside a row and owns its own keys.
|
||||
|
||||
@@ -74,7 +74,7 @@ export class ConnectionPool {
|
||||
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
|
||||
* exists (refreshing LRU recency). Does not touch other mounted panes.
|
||||
*/
|
||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
|
||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
|
||||
let conn = this.cache.get(id);
|
||||
if (conn) {
|
||||
this.cache.delete(id);
|
||||
@@ -93,9 +93,9 @@ export class ConnectionPool {
|
||||
}
|
||||
if (conn.el.parentElement !== pane) {
|
||||
pane.replaceChildren(conn.el);
|
||||
conn.onMount();
|
||||
conn.onMount(opts);
|
||||
} else if (!this.mounted.has(id)) {
|
||||
conn.onMount();
|
||||
conn.onMount(opts);
|
||||
}
|
||||
this.mounted.add(id);
|
||||
this.evict();
|
||||
@@ -114,13 +114,17 @@ export class ConnectionPool {
|
||||
/**
|
||||
* Reconcile the visible set to exactly `entries` (in render order): park any
|
||||
* mounted session not listed, mount/refresh each listed one in its pane.
|
||||
* `focus: false` shows the panes without taking the keyboard (preview).
|
||||
*/
|
||||
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void {
|
||||
setVisible(
|
||||
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
|
||||
opts?: { focus?: boolean },
|
||||
): void {
|
||||
const next = new Set(entries.map((e) => e.id));
|
||||
for (const id of [...this.mounted]) {
|
||||
if (!next.has(id)) this.unmount(id);
|
||||
}
|
||||
for (const e of entries) this.mount(e.id, e.pane, e.info);
|
||||
for (const e of entries) this.mount(e.id, e.pane, e.info, opts);
|
||||
}
|
||||
|
||||
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
||||
|
||||
+27
-2
@@ -104,9 +104,25 @@ shellEl.dataset.route = "catalog";
|
||||
// after the welcome renderer). The hook only fires on user interaction, by which
|
||||
// point `mv` is assigned.
|
||||
let mv: MultiView;
|
||||
|
||||
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
|
||||
// mouse sweep waits long enough that rows crossed on the way somewhere else
|
||||
// never flash up. The same delay applies to clearing, so the gap between two
|
||||
// rows doesn't blink the previous view back.
|
||||
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
|
||||
let previewTimer: number | undefined;
|
||||
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
|
||||
clearTimeout(previewTimer);
|
||||
previewTimer = window.setTimeout(() => {
|
||||
showMultiView();
|
||||
mv?.previewSession(sessionId);
|
||||
}, PREVIEW_DELAY[source]);
|
||||
}
|
||||
|
||||
const sidebar = renderSidebar(store, {
|
||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||
link,
|
||||
onPreview: schedulePreview,
|
||||
});
|
||||
const content = document.createElement("section");
|
||||
content.className = "content";
|
||||
@@ -184,7 +200,9 @@ function infoFor(sessionId: string) {
|
||||
|
||||
/** Drive the shell's responsive layout off whether any pane is open. */
|
||||
function updateShellRoute(): void {
|
||||
const hasPanes = openSessions(layout).size > 0;
|
||||
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
|
||||
// not swap the list out for a terminal on the way past a row.
|
||||
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
|
||||
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
|
||||
}
|
||||
|
||||
@@ -375,7 +393,13 @@ window.addEventListener("keydown", (e) => {
|
||||
|
||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
||||
// being provisional rather than snapping back the moment the list blurs.
|
||||
const previewed = mv.previewedSessionId();
|
||||
if (previewed) focusSession(previewed);
|
||||
pool.focusTerminal();
|
||||
});
|
||||
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
@@ -386,6 +410,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||
* changing, leaving a click back onto that session's row silently ignored. */
|
||||
function focusSession(sessionId: string): void {
|
||||
clearTimeout(previewTimer);
|
||||
showMultiView();
|
||||
mv.openSession(sessionId);
|
||||
updateShellRoute();
|
||||
|
||||
+165
-12
@@ -11,6 +11,7 @@ import {
|
||||
addTab,
|
||||
closeTab,
|
||||
findLeaf,
|
||||
freshId,
|
||||
type Dir,
|
||||
type Layout,
|
||||
leaves,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
openSessions,
|
||||
removeLeaf,
|
||||
splitLeaf,
|
||||
type Tab,
|
||||
} from "./layout.js";
|
||||
import type { AttachInfo } from "./attach.js";
|
||||
|
||||
@@ -57,11 +59,22 @@ export interface MultiView {
|
||||
/** Rebuild the view from the current layout. */
|
||||
render: () => void;
|
||||
focusedSessionId: () => string | null;
|
||||
/** Show `sessionId` in the content pane without committing to it
|
||||
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
|
||||
* tab carries it. `null` puts back whatever was on screen before. */
|
||||
previewSession: (sessionId: string | null) => void;
|
||||
/** The session being previewed, or null. */
|
||||
previewedSessionId: () => string | null;
|
||||
}
|
||||
|
||||
export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||
const persist = () => opts.onLayoutChange?.();
|
||||
// A preview must never reach localStorage: it is undone the moment the
|
||||
// highlight moves on, and the layout it would save is not the operator's.
|
||||
const persist = () => {
|
||||
if (preview) return;
|
||||
opts.onLayoutChange?.();
|
||||
};
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "mv";
|
||||
@@ -83,8 +96,105 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
render();
|
||||
});
|
||||
|
||||
// ---- preview (docs/PREVIEW.md) ----
|
||||
// A preview is a temporary *view* change, never a layout change: it records
|
||||
// exactly what it moved so ending it puts the layout back byte for byte, and
|
||||
// it neither persists nor reports pane focus to the daemon while it is up.
|
||||
interface Preview {
|
||||
sessionId: string;
|
||||
/** Tab that was active before the preview. */
|
||||
prevActiveTabId: string;
|
||||
/** Focused leaf to restore, when the preview stole focus in a real tab. */
|
||||
prevFocus: { tabId: string; leafId: string | null } | null;
|
||||
/** Throwaway tab built for a session that no pane was showing. */
|
||||
tempTabId: string | null;
|
||||
}
|
||||
let preview: Preview | null = null;
|
||||
|
||||
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
|
||||
|
||||
/** Undo the preview's moves. Mutates only — the caller renders. */
|
||||
function restorePreview(): boolean {
|
||||
const p = preview;
|
||||
if (!p) return false;
|
||||
preview = null;
|
||||
if (p.tempTabId) {
|
||||
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
|
||||
if (i >= 0) layout.tabs.splice(i, 1);
|
||||
} else if (p.prevFocus) {
|
||||
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
|
||||
if (t) t.focusedLeafId = p.prevFocus.leafId;
|
||||
}
|
||||
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
|
||||
? p.prevActiveTabId
|
||||
: layout.tabs[0].id;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Keep what the preview is showing: its throwaway tab becomes a real one,
|
||||
* and the focus it moved stands. Returns true when `sessionId` was the
|
||||
* session being previewed; any other preview is simply discarded. */
|
||||
function settlePreview(sessionId: string): boolean {
|
||||
const p = preview;
|
||||
if (!p) return false;
|
||||
if (p.sessionId !== sessionId) {
|
||||
restorePreview();
|
||||
return false;
|
||||
}
|
||||
preview = null;
|
||||
if (p.tempTabId) {
|
||||
const t = layout.tabs.find((x) => x.id === p.tempTabId);
|
||||
if (t) t.title = `Tab ${layout.tabs.length}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function previewSession(sessionId: string | null): void {
|
||||
if (preview?.sessionId === sessionId) return;
|
||||
if (sessionId === null) {
|
||||
if (restorePreview()) render();
|
||||
return;
|
||||
}
|
||||
restorePreview();
|
||||
const prevActiveTabId = layout.activeTabId;
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
preview = {
|
||||
sessionId,
|
||||
prevActiveTabId,
|
||||
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
|
||||
tempTabId: null,
|
||||
};
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
} else {
|
||||
// Not on screen anywhere: a throwaway tab carries it, so no existing
|
||||
// pane is evicted and nothing the operator arranged moves.
|
||||
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
layout.tabs.push(tab);
|
||||
layout.activeTabId = tab.id;
|
||||
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/** Any user action that changes the layout for real ends the preview first,
|
||||
* so it never bakes a throwaway tab into the arrangement. */
|
||||
function dropPreview(): void {
|
||||
restorePreview();
|
||||
}
|
||||
|
||||
// ---- highlight helpers ----
|
||||
|
||||
/** Sessions the operator actually has open — the preview's throwaway tab
|
||||
* does not count, or hovering the sidebar would flip the shell's route. */
|
||||
function realOpenSessions(): Set<string> {
|
||||
const out = openSessions(layout);
|
||||
if (preview?.tempTabId) out.delete(preview.sessionId);
|
||||
return out;
|
||||
}
|
||||
|
||||
function focusedLeafSession(): string | null {
|
||||
const tab = activeTab(layout);
|
||||
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
||||
@@ -98,9 +208,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
||||
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
||||
}
|
||||
pool.setFocus(focusedLeafSession());
|
||||
// A preview is not a focused pane: reporting it would clear the session's
|
||||
// `AwaitingInput` glow just because the operator scrolled past it.
|
||||
if (!preview) pool.setFocus(focusedLeafSession());
|
||||
sidebar.setHighlights({
|
||||
open: openSessions(layout),
|
||||
open: realOpenSessions(),
|
||||
focused: focusedLeafSession(),
|
||||
});
|
||||
}
|
||||
@@ -168,6 +280,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
pane.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
// Clicking into a previewed pane is the operator choosing it.
|
||||
if (preview && leaf.sessionId === preview.sessionId) {
|
||||
settlePreview(leaf.sessionId);
|
||||
persist();
|
||||
}
|
||||
focusLeaf(leaf.id);
|
||||
// Drag a pane by its title bar (desktop only — no docking on mobile).
|
||||
if (isMobile) return;
|
||||
@@ -252,16 +369,21 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
for (const t of layout.tabs) {
|
||||
const tab = document.createElement("button");
|
||||
tab.type = "button";
|
||||
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
|
||||
tab.className =
|
||||
"mv-tab" +
|
||||
(t.id === layout.activeTabId ? " is-active" : "") +
|
||||
(isTempTab(t.id) ? " is-preview" : "");
|
||||
const n = leaves(t.root).filter((l) => l.sessionId).length;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = t.title + (n ? ` · ${n}` : "");
|
||||
tab.appendChild(label);
|
||||
tab.addEventListener("click", () => {
|
||||
if (isTempTab(t.id)) return; // a preview tab is not a place to go
|
||||
dropPreview();
|
||||
if (layout.activeTabId !== t.id) {
|
||||
layout.activeTabId = t.id;
|
||||
render();
|
||||
}
|
||||
render();
|
||||
});
|
||||
if (layout.tabs.length > 1) {
|
||||
const close = document.createElement("span");
|
||||
@@ -270,6 +392,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
close.title = "Close tab";
|
||||
close.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
dropPreview();
|
||||
closeTab(layout, t.id);
|
||||
render();
|
||||
});
|
||||
@@ -283,6 +406,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
add.textContent = "+";
|
||||
add.title = "New tab";
|
||||
add.addEventListener("click", () => {
|
||||
dropPreview();
|
||||
addTab(layout);
|
||||
render();
|
||||
});
|
||||
@@ -292,12 +416,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// ---- mutations ----
|
||||
|
||||
function splitFocused(leafId: string, dir: Dir): void {
|
||||
dropPreview();
|
||||
const tab = activeTab(layout);
|
||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||
render();
|
||||
}
|
||||
|
||||
function closePane(leafId: string): void {
|
||||
dropPreview();
|
||||
const tab = activeTab(layout);
|
||||
removeLeaf(tab, leafId);
|
||||
if (tab.focusedLeafId === leafId) {
|
||||
@@ -307,6 +433,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
}
|
||||
|
||||
function openSession(sessionId: string): void {
|
||||
// Opening what is already previewed keeps exactly what is on screen: the
|
||||
// throwaway tab becomes a real one instead of the session moving again.
|
||||
if (settlePreview(sessionId)) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
@@ -337,6 +469,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
|
||||
/// operator was reading (#100) — what the Spawn button asks for.
|
||||
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
|
||||
if (settlePreview(sessionId)) {
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
return;
|
||||
}
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
@@ -384,13 +521,19 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// A lone tab has nothing to switch between, so the bar is only a strip of
|
||||
// chrome; CSS hides it until a second tab exists. Spawning still opens its
|
||||
// own tab (#100), which brings the bar back with the "+" button on it.
|
||||
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
|
||||
// The preview's throwaway tab must not make the bar appear and vanish as
|
||||
// the highlight moves, so it does not count towards "one tab only".
|
||||
el.classList.toggle(
|
||||
"mv-solo-tab",
|
||||
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
|
||||
);
|
||||
el.classList.toggle("mv-previewing", preview !== null);
|
||||
|
||||
if (!tab.root) {
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
if (!preview) pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
sidebar.setHighlights({ open: openSessions(layout), focused: null });
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
@@ -408,7 +551,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||
canvas.appendChild(buildTree(tab.root, hosts));
|
||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||
// A preview keeps the keyboard in the sidebar: mounting must not focus the
|
||||
// terminal, or j/k would stop walking the list after the first step.
|
||||
pool.setVisible(
|
||||
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
|
||||
{ focus: !preview },
|
||||
);
|
||||
syncFocus();
|
||||
persist();
|
||||
}
|
||||
@@ -431,12 +579,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// Only the focused leaf's session is live on mobile; the rest park.
|
||||
pool.setVisible(
|
||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
||||
{ focus: !preview },
|
||||
);
|
||||
pool.setFocus(focused.sessionId ?? null);
|
||||
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
|
||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||
}
|
||||
|
||||
function closeSession(sessionId: string): void {
|
||||
dropPreview();
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (!loc) return;
|
||||
removeLeaf(loc.tab, loc.leaf.id);
|
||||
@@ -628,6 +778,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
}
|
||||
|
||||
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
|
||||
dropPreview();
|
||||
runDrag({ kind: "session", sessionId }, e, { deferred: true });
|
||||
}
|
||||
|
||||
@@ -636,9 +787,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
openSession,
|
||||
openSessionBeside,
|
||||
closeSession,
|
||||
openSessions: () => openSessions(layout),
|
||||
openSessions: realOpenSessions,
|
||||
beginSessionDrag,
|
||||
render,
|
||||
focusedSessionId: focusedLeafSession,
|
||||
previewSession,
|
||||
previewedSessionId: () => preview?.sessionId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,11 @@ const A = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
|
||||
|
||||
/** Two sessions in sibling directories: two folder headers, two cards. */
|
||||
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
|
||||
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
|
||||
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
|
||||
el: HTMLElement;
|
||||
handle: ReturnType<typeof renderSidebar>;
|
||||
} {
|
||||
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
|
||||
// Mounted: `focusList` moves real DOM focus, which needs a live document.
|
||||
document.body.replaceChildren(handle.el);
|
||||
return { el: handle.el, handle };
|
||||
@@ -126,3 +129,43 @@ describe("sidebar keyboard navigation", () => {
|
||||
expect(highlighted(el)).toBe(`session:${A}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
||||
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
|
||||
const seen: [string | null, string][] = [];
|
||||
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
||||
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "j");
|
||||
expect(seen).toEqual([
|
||||
[A, "key"],
|
||||
[B, "key"],
|
||||
]);
|
||||
|
||||
tree(el).dispatchEvent(new FocusEvent("blur"));
|
||||
expect(seen.at(-1)).toEqual([null, "key"]);
|
||||
});
|
||||
|
||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||
const seen: [string | null, string][] = [];
|
||||
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||
expect(seen).toEqual([[A, "hover"]]);
|
||||
// Moving within the same card is not a new preview.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||
expect(seen.at(-1)).toEqual([null, "hover"]);
|
||||
});
|
||||
|
||||
it("ignores touch, where a tap already opens the session", () => {
|
||||
const seen: (string | null)[] = [];
|
||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2883,6 +2883,23 @@ input.leaf-rename:focus {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
|
||||
operator only highlighted, so it reads as provisional — a dashed tab and a
|
||||
dashed inset ring on the canvas — and reverts when the highlight moves. */
|
||||
.mv-tab.is-preview {
|
||||
border-style: dashed;
|
||||
font-style: italic;
|
||||
}
|
||||
.mv.mv-previewing .mv-canvas::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border: 1px dashed var(--accent-ring);
|
||||
border-radius: var(--r-1);
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
|
||||
/* canvas + welcome */
|
||||
.mv-canvas {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user