feat(maestro): multi-view web UI — splits, tabs, drag-dock, highlights
ci / rust (push) Successful in 5m46s
ci / rust (push) Successful in 5m46s
Render the content pane as tabs of nested split trees, each session leaf a live attach mounted through the multi-mount pool: - #30 arbitrarily-nested H/V splits with mouse-resizable dividers; per-pane ⏻/▥/▤/✕ tools; closing a pane collapses its parent split. - #31 tabs, each holding its own tree; add / switch / close. - #32 drag-to-dock: drag a pane title or a sidebar session onto a pane for a 5-zone dock (edges split, centre swaps/replaces); a sidebar drag moves an already-open session. - #33 focused pane at full strength, others dim; sidebar shows focused (loud) vs open-elsewhere (quiet) with a stable per-session colour mapping rows to panes. Opening a session fills the focused pane (single-pane parity by default); multi-pane is opt-in via split / drag / tabs. Panes auto-close when their session ends (guarded against eviction and intentional close). The steady "connected" label is hidden as noise. Closes #30, #31, #32, #33. Part of epic #37. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,10 @@ export interface AttachConnection {
|
||||
export interface AttachOptions {
|
||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||||
onRequestClose?: (sessionId: Uuid) => void;
|
||||
/** Invoked when the socket closes on its own — the session ended or the link
|
||||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
||||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
||||
onClosed?: (sessionId: Uuid) => void;
|
||||
}
|
||||
|
||||
/** What the header shows about the session (name / command / host). Optional —
|
||||
@@ -242,6 +246,9 @@ export function createAttachConnection(
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
setState(`closed (${ev.code})`, "closed");
|
||||
// Only bubble up server/network closes — an intentional dispose has already
|
||||
// set `disposed`, and eviction/user-close handle their own pane teardown.
|
||||
if (!disposed) opts.onClosed?.(sessionId);
|
||||
};
|
||||
|
||||
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
|
||||
|
||||
+58
-15
@@ -337,12 +337,24 @@ export function renderTopbar(store: CatalogStore): HTMLElement {
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
||||
* pane (quiet), which one is focused (loud), and a stable per-session colour so
|
||||
* a row maps to its pane. */
|
||||
export interface SidebarHighlights {
|
||||
open: Set<string>;
|
||||
focused: string | null;
|
||||
colors: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
||||
export interface SidebarHandle {
|
||||
/** The `<aside>` element to mount into the shell. */
|
||||
el: HTMLElement;
|
||||
/** Highlight the row for `id` (or clear it with `null`) as the open session. */
|
||||
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
||||
* Back-compat shim over {@link setHighlights}. */
|
||||
setActive(id: string | null): void;
|
||||
/** Apply the full open/focused/colour highlight state. */
|
||||
setHighlights(h: SidebarHighlights): void;
|
||||
/** Tear down the store subscription + listeners. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -354,7 +366,13 @@ export interface SidebarHandle {
|
||||
* while the content pane swaps between sessions — so the store subscription and
|
||||
* scroll position survive navigation. The active session is highlighted.
|
||||
*/
|
||||
export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
export interface SidebarOptions {
|
||||
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
||||
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
||||
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
const el = document.createElement("aside");
|
||||
el.className = "sidebar";
|
||||
|
||||
@@ -378,6 +396,21 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
treeHost.className = "tree";
|
||||
el.appendChild(treeHost);
|
||||
|
||||
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
||||
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
||||
treeHost.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0 || !opts.onLeafDrag) return;
|
||||
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
|
||||
const id = leaf?.dataset.sessionId;
|
||||
if (id) opts.onLeafDrag(id, e);
|
||||
});
|
||||
// The rows are <a> elements, so the browser's native link drag-and-drop would
|
||||
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
|
||||
// until release. Suppress it so the pointer-based drag runs from the start.
|
||||
treeHost.addEventListener("dragstart", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
|
||||
});
|
||||
|
||||
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
|
||||
// width as a :root CSS var the shell grid reads; clamped to a sane range.
|
||||
const SIDEBAR_W_KEY = "maestro-sidebar-w";
|
||||
@@ -425,16 +458,18 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
refresh();
|
||||
});
|
||||
|
||||
let activeId: string | null = null;
|
||||
const applyActive = () => {
|
||||
for (const n of treeHost.querySelectorAll(".tree-leaf.is-active")) {
|
||||
n.classList.remove("is-active");
|
||||
}
|
||||
if (activeId) {
|
||||
const row = treeHost.querySelector(
|
||||
`.tree-leaf[data-session-id="${CSS.escape(activeId)}"]`,
|
||||
);
|
||||
row?.classList.add("is-active");
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
|
||||
const applyHighlights = () => {
|
||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const id = n.dataset.sessionId;
|
||||
if (!id) continue;
|
||||
const focused = highlights.focused === id;
|
||||
const open = highlights.open.has(id);
|
||||
n.classList.toggle("is-active", focused);
|
||||
n.classList.toggle("is-open", open && !focused);
|
||||
const color = highlights.colors.get(id);
|
||||
if (color && (open || focused)) n.style.setProperty("--mv-color", color);
|
||||
else n.style.removeProperty("--mv-color");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -455,7 +490,7 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
if (tree.kind === "folder") {
|
||||
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
||||
}
|
||||
applyActive();
|
||||
applyHighlights();
|
||||
};
|
||||
|
||||
const rerenderHandler = () => refresh();
|
||||
@@ -484,8 +519,16 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
return {
|
||||
el,
|
||||
setActive(id: string | null) {
|
||||
activeId = id;
|
||||
applyActive();
|
||||
highlights = {
|
||||
open: id ? new Set([id]) : new Set(),
|
||||
focused: id,
|
||||
colors: new Map(),
|
||||
};
|
||||
applyHighlights();
|
||||
},
|
||||
setHighlights(h: SidebarHighlights) {
|
||||
highlights = h;
|
||||
applyHighlights();
|
||||
},
|
||||
dispose() {
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface PoolOptions {
|
||||
/** Fired when a view's explicit disconnect control is used; the embedder
|
||||
* decides how to update the layout/route (then typically calls `close`). */
|
||||
onRequestClose?: (id: Uuid) => void;
|
||||
/** Fired when a connection's socket closes on its own (session ended/dropped),
|
||||
* not via an intentional close/eviction. The embedder closes the pane. */
|
||||
onClosed?: (id: Uuid) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +79,10 @@ export class ConnectionPool {
|
||||
} else {
|
||||
conn = createAttachConnection(
|
||||
id,
|
||||
{ onRequestClose: (sid) => this.opts.onRequestClose?.(sid) },
|
||||
{
|
||||
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
||||
onClosed: (sid) => this.opts.onClosed?.(sid),
|
||||
},
|
||||
info,
|
||||
);
|
||||
this.cache.set(id, conn);
|
||||
|
||||
+97
-7
@@ -1,15 +1,18 @@
|
||||
// Multi-view layout model. The layout tree is the single source of truth for
|
||||
// what's on screen: a set of tabs, each holding a binary split tree whose leaves
|
||||
// are sessions. Desktop renders the tree as nested splits; mobile projects its
|
||||
// leaves to tabs. (Splits/tabs UI land in later issues — see epic #37. This
|
||||
// issue introduces the model and renders a single-leaf tree at parity.)
|
||||
// hold a session (or nothing yet). Desktop renders the tree as nested splits;
|
||||
// mobile (issue #35) projects its leaves to tabs.
|
||||
//
|
||||
// A session lives in at most one leaf at a time (the connection pool is
|
||||
// one-socket-per-session), so a leaf's `sessionId` is unique across the whole
|
||||
// layout — or null for a freshly-split, not-yet-filled pane.
|
||||
|
||||
import type { Uuid } from "./types.js";
|
||||
|
||||
export type Dir = "row" | "col";
|
||||
|
||||
export type Node =
|
||||
| { kind: "leaf"; id: string; sessionId: Uuid }
|
||||
| { kind: "leaf"; id: string; sessionId: Uuid | null }
|
||||
| { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
|
||||
|
||||
export type Leaf = Extract<Node, { kind: "leaf" }>;
|
||||
@@ -19,7 +22,7 @@ export interface Tab {
|
||||
title: string;
|
||||
/** The split tree, or null for an empty tab. */
|
||||
root: Node | null;
|
||||
/** Which leaf has keyboard focus / drives the sidebar highlight. */
|
||||
/** Which leaf has focus (drives keyboard input + the strong highlight). */
|
||||
focusedLeafId: string | null;
|
||||
}
|
||||
|
||||
@@ -29,8 +32,6 @@ export interface Layout {
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
/** Monotonic id for tabs and leaves (not persisted across reloads — restore
|
||||
* assigns fresh ids). */
|
||||
export function freshId(prefix = "n"): string {
|
||||
return `${prefix}${++seq}`;
|
||||
}
|
||||
@@ -50,8 +51,97 @@ export function activeTab(layout: Layout): Tab {
|
||||
return layout.tabs.find((t) => t.id === layout.activeTabId) ?? layout.tabs[0];
|
||||
}
|
||||
|
||||
/** Distinct sessions shown in any pane of any tab. */
|
||||
export function openSessions(layout: Layout): Set<Uuid> {
|
||||
const out = new Set<Uuid>();
|
||||
for (const t of layout.tabs) {
|
||||
for (const l of leaves(t.root)) if (l.sessionId) out.add(l.sessionId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Find the tab + leaf currently showing `sessionId`, if any. */
|
||||
export function locateSession(
|
||||
layout: Layout,
|
||||
sessionId: Uuid,
|
||||
): { tab: Tab; leaf: Leaf } | null {
|
||||
for (const tab of layout.tabs) {
|
||||
const leaf = leaves(tab.root).find((l) => l.sessionId === sessionId);
|
||||
if (leaf) return { tab, leaf };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A layout with one empty tab. */
|
||||
export function emptyLayout(): Layout {
|
||||
const tab: Tab = { id: freshId("t"), title: "Tab 1", root: null, focusedLeafId: null };
|
||||
return { tabs: [tab], activeTabId: tab.id };
|
||||
}
|
||||
|
||||
// ---- mutations (return the affected leaf id where useful) ----
|
||||
|
||||
/** Replace `targetId` with a split of [target, fresh] (or reversed when
|
||||
* `after` is false). Returns the new leaf's id. */
|
||||
export function splitLeaf(
|
||||
tab: Tab,
|
||||
targetId: string,
|
||||
sessionId: Uuid | null,
|
||||
dir: Dir,
|
||||
after: boolean,
|
||||
): string {
|
||||
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId };
|
||||
const rec = (n: Node): Node => {
|
||||
if (n.kind === "leaf") {
|
||||
if (n.id !== targetId) return n;
|
||||
return { kind: "split", dir, ratio: 0.5, a: after ? n : fresh, b: after ? fresh : n };
|
||||
}
|
||||
n.a = rec(n.a);
|
||||
n.b = rec(n.b);
|
||||
return n;
|
||||
};
|
||||
tab.root = tab.root ? rec(tab.root) : fresh;
|
||||
return fresh.id;
|
||||
}
|
||||
|
||||
/** Remove a leaf, collapsing its parent split into the sibling. */
|
||||
export function removeLeaf(tab: Tab, leafId: string): void {
|
||||
const rec = (n: Node): Node | null => {
|
||||
if (n.kind === "leaf") return n.id === leafId ? null : n;
|
||||
const a = rec(n.a);
|
||||
const b = rec(n.b);
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
n.a = a;
|
||||
n.b = b;
|
||||
return n;
|
||||
};
|
||||
tab.root = tab.root ? rec(tab.root) : null;
|
||||
}
|
||||
|
||||
export function addTab(layout: Layout): Tab {
|
||||
const tab: Tab = {
|
||||
id: freshId("t"),
|
||||
title: `Tab ${layout.tabs.length + 1}`,
|
||||
root: null,
|
||||
focusedLeafId: null,
|
||||
};
|
||||
layout.tabs.push(tab);
|
||||
layout.activeTabId = tab.id;
|
||||
return tab;
|
||||
}
|
||||
|
||||
export function closeTab(layout: Layout, tabId: string): void {
|
||||
if (layout.tabs.length <= 1) {
|
||||
// Keep at least one tab; just empty it.
|
||||
const t = layout.tabs[0];
|
||||
t.root = null;
|
||||
t.focusedLeafId = null;
|
||||
return;
|
||||
}
|
||||
const i = layout.tabs.findIndex((t) => t.id === tabId);
|
||||
if (i < 0) return;
|
||||
layout.tabs.splice(i, 1);
|
||||
if (layout.activeTabId === tabId) {
|
||||
layout.activeTabId = layout.tabs[Math.max(0, i - 1)].id;
|
||||
}
|
||||
}
|
||||
|
||||
+54
-55
@@ -3,15 +3,8 @@ import { ConnectionPool } from "./connections.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { mountHud, type Hud } from "./hud.js";
|
||||
import {
|
||||
activeTab,
|
||||
emptyLayout,
|
||||
findLeaf,
|
||||
freshId,
|
||||
leaves,
|
||||
type Layout,
|
||||
type Node,
|
||||
} from "./layout.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { openSpawnModal } from "./spawn.js";
|
||||
import { initTheme } from "./theme.js";
|
||||
import { sessionName } from "./types.js";
|
||||
@@ -67,7 +60,13 @@ const shellEl = document.createElement("div");
|
||||
shellEl.className = "app-shell";
|
||||
shellEl.dataset.route = "catalog";
|
||||
|
||||
const sidebar = renderSidebar(store);
|
||||
// Forward-declared so the sidebar's drag hook can reach the controller (created
|
||||
// after the welcome renderer). The hook only fires on user interaction, by which
|
||||
// point `mv` is assigned.
|
||||
let mv: MultiView;
|
||||
const sidebar = renderSidebar(store, {
|
||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||
});
|
||||
const content = document.createElement("section");
|
||||
content.className = "content";
|
||||
|
||||
@@ -79,27 +78,35 @@ root.replaceChildren(renderTopbar(store), shellEl);
|
||||
// It can mount several panes at once — the layout tree decides how many. For now
|
||||
// the tree only ever holds a single leaf (splits/tabs arrive in epic #37); this
|
||||
// routes the existing single-session view through that model.
|
||||
// `mv` is forward-declared above (the sidebar drag hook needs it); the pool's
|
||||
// disconnect callback below also references it.
|
||||
let hud: Hud | undefined;
|
||||
const pool = new ConnectionPool({
|
||||
onFocusChange: (id) => {
|
||||
sidebar.setActive(id);
|
||||
onFocusChange: () => {
|
||||
// Sidebar highlighting is driven by the multi-view controller; the pool only
|
||||
// nudges the HUD here.
|
||||
hud?.refresh();
|
||||
},
|
||||
onRequestClose: (id) => {
|
||||
pool.close(id);
|
||||
// Drop back to the welcome pane via the router.
|
||||
if (location.hash !== "#/" && location.hash !== "") location.hash = "#/";
|
||||
},
|
||||
// Both the explicit disconnect (⏻) and a session ending on its own close the
|
||||
// pane — drop its leaf (collapsing the split) and tear down the socket. A dead
|
||||
// pane is never left lying around.
|
||||
onRequestClose: (id) => closePane(id),
|
||||
onClosed: (id) => closePane(id),
|
||||
});
|
||||
|
||||
function closePane(id: string): void {
|
||||
pool.close(id);
|
||||
mv?.closeSession(id);
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
// Debug HUD (#20): a fixed-corner overlay on the active session's inferred
|
||||
// fields, off by default (Ctrl+Shift+D, or `?hud` in dev).
|
||||
hud = mountHud(root, store, () => pool.focused());
|
||||
|
||||
// ---- layout state ----
|
||||
// Single source of truth for what's mounted. Today it's derived from the hash
|
||||
// route (one leaf per `#/attach/:id`); future issues let the user build splits
|
||||
// and tabs on top of the same model.
|
||||
// Single source of truth for what's mounted: tabs of split trees. The hash route
|
||||
// is just a deep-link into it (`#/attach/:id` focuses/opens a session).
|
||||
const layout: Layout = emptyLayout();
|
||||
|
||||
function infoFor(sessionId: string) {
|
||||
@@ -107,32 +114,10 @@ function infoFor(sessionId: string) {
|
||||
return s ? { name: sessionName(s), command: s.command, daemon: s.daemon_id } : undefined;
|
||||
}
|
||||
|
||||
/** Show a single session in the active tab. */
|
||||
function openInLayout(sessionId: string): void {
|
||||
const tab = activeTab(layout);
|
||||
const leaf: Node = { kind: "leaf", id: freshId("l"), sessionId };
|
||||
tab.root = leaf;
|
||||
tab.focusedLeafId = leaf.id;
|
||||
}
|
||||
|
||||
/** Empty the active tab (welcome / not-found). */
|
||||
function clearLayout(): void {
|
||||
const tab = activeTab(layout);
|
||||
tab.root = null;
|
||||
tab.focusedLeafId = null;
|
||||
}
|
||||
|
||||
/** Reconcile the pool's visible panes + focus with the active tab's tree. */
|
||||
function renderLayout(): void {
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root) {
|
||||
pool.parkActive();
|
||||
return;
|
||||
}
|
||||
const ls = leaves(tab.root);
|
||||
pool.setVisible(ls.map((l) => ({ id: l.sessionId, pane: content, info: infoFor(l.sessionId) })));
|
||||
const focusLeaf = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : ls[0];
|
||||
pool.setFocus(focusLeaf?.sessionId ?? null);
|
||||
/** Drive the shell's responsive layout off whether any pane is open. */
|
||||
function updateShellRoute(): void {
|
||||
const hasPanes = openSessions(layout).size > 0;
|
||||
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
|
||||
}
|
||||
|
||||
function renderWelcome(): HTMLElement {
|
||||
@@ -168,6 +153,19 @@ function renderNotFound(raw: string): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
// The multi-view controller renders tabs + splits inside the content pane and
|
||||
// mounts a live attach into each session leaf.
|
||||
mv = createMultiView({ pool, layout, sidebar, infoFor, onDisconnect: closePane, renderWelcome });
|
||||
let contentShowsMv = false;
|
||||
function showMultiView(): void {
|
||||
if (!contentShowsMv) {
|
||||
content.replaceChildren(mv.el);
|
||||
contentShowsMv = true;
|
||||
}
|
||||
mv.render();
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
@@ -185,21 +183,22 @@ function dispatch(): void {
|
||||
const route = parseRoute();
|
||||
switch (route.kind) {
|
||||
case "attach":
|
||||
shellEl.dataset.route = "attach";
|
||||
openInLayout(route.sessionId);
|
||||
renderLayout();
|
||||
// Deep-link: open/focus the session, then show the layout.
|
||||
showMultiView();
|
||||
mv.openSession(route.sessionId);
|
||||
updateShellRoute();
|
||||
return;
|
||||
case "not-found":
|
||||
shellEl.dataset.route = "notfound";
|
||||
clearLayout();
|
||||
renderLayout();
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
content.replaceChildren(renderNotFound(route.raw));
|
||||
contentShowsMv = false;
|
||||
return;
|
||||
case "catalog":
|
||||
shellEl.dataset.route = "catalog";
|
||||
clearLayout();
|
||||
renderLayout();
|
||||
content.replaceChildren(renderWelcome());
|
||||
// Show the current layout (welcome if empty) — navigating to #/ no longer
|
||||
// tears panes down.
|
||||
showMultiView();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
// The multi-view renderer/controller (issues #30 splits, #31 tabs, #33
|
||||
// highlights). Owns the tab bar + canvas inside the content pane, builds the
|
||||
// active tab's split tree into DOM, and mounts a live AttachConnection into each
|
||||
// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a
|
||||
// placeholder. Drag-to-dock is a later issue (#32); mobile projection is #35.
|
||||
|
||||
import type { SidebarHandle } from "./catalog.js";
|
||||
import type { ConnectionPool } from "./connections.js";
|
||||
import {
|
||||
activeTab,
|
||||
addTab,
|
||||
closeTab,
|
||||
findLeaf,
|
||||
type Dir,
|
||||
type Layout,
|
||||
leaves,
|
||||
locateSession,
|
||||
type Node,
|
||||
openSessions,
|
||||
removeLeaf,
|
||||
splitLeaf,
|
||||
} from "./layout.js";
|
||||
import { sessionColor } from "./sessioncolor.js";
|
||||
import type { AttachInfo } from "./attach.js";
|
||||
|
||||
export interface MultiViewOptions {
|
||||
pool: ConnectionPool;
|
||||
layout: Layout;
|
||||
sidebar: SidebarHandle;
|
||||
/** Header text for a session's pane / attach view. */
|
||||
infoFor: (sessionId: string) => AttachInfo | undefined;
|
||||
/** Disconnect a session (the ⏻ tool): tear down its socket and close the pane. */
|
||||
onDisconnect: (sessionId: string) => void;
|
||||
/** Welcome content for an empty tab. */
|
||||
renderWelcome: () => HTMLElement;
|
||||
}
|
||||
|
||||
export interface MultiView {
|
||||
el: HTMLElement;
|
||||
/** Bring a session into the layout: focus it if already open, else fill the
|
||||
* focused pane (or open the first pane in an empty tab). */
|
||||
openSession: (sessionId: string) => void;
|
||||
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
|
||||
closeSession: (sessionId: string) => void;
|
||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||
* drag shows drop zones to place it. */
|
||||
beginSessionDrag: (sessionId: string, e: PointerEvent) => void;
|
||||
/** Rebuild the view from the current layout. */
|
||||
render: () => void;
|
||||
focusedSessionId: () => string | null;
|
||||
}
|
||||
|
||||
export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "mv";
|
||||
const tabBar = document.createElement("div");
|
||||
tabBar.className = "mv-tabs";
|
||||
const canvas = document.createElement("div");
|
||||
canvas.className = "mv-canvas";
|
||||
el.append(tabBar, canvas);
|
||||
|
||||
// ---- highlight helpers ----
|
||||
|
||||
function colorMap(): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
|
||||
return m;
|
||||
}
|
||||
|
||||
function focusedLeafSession(): string | null {
|
||||
const tab = activeTab(layout);
|
||||
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
||||
return fl?.sessionId ?? null;
|
||||
}
|
||||
|
||||
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
||||
* the DOM (cheap path for click-to-focus). */
|
||||
function syncFocus(): void {
|
||||
const tab = activeTab(layout);
|
||||
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
||||
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
||||
}
|
||||
pool.setFocus(focusedLeafSession());
|
||||
sidebar.setHighlights({
|
||||
open: openSessions(layout),
|
||||
focused: focusedLeafSession(),
|
||||
colors: colorMap(),
|
||||
});
|
||||
}
|
||||
|
||||
function focusLeaf(leafId: string): void {
|
||||
const tab = activeTab(layout);
|
||||
if (tab.focusedLeafId === leafId) return;
|
||||
tab.focusedLeafId = leafId;
|
||||
syncFocus();
|
||||
}
|
||||
|
||||
// ---- pane + split DOM ----
|
||||
|
||||
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
|
||||
el: HTMLElement;
|
||||
host: HTMLElement;
|
||||
sessionId: string | null;
|
||||
} {
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "mv-pane";
|
||||
pane.dataset.leafId = leaf.id;
|
||||
if (leaf.sessionId) {
|
||||
pane.dataset.sessionId = leaf.sessionId;
|
||||
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
|
||||
}
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "mv-pane-host";
|
||||
if (!leaf.sessionId) {
|
||||
host.innerHTML = `<div class="mv-pane-empty">Empty pane — pick a session from the sidebar.</div>`;
|
||||
}
|
||||
|
||||
const tools = document.createElement("div");
|
||||
tools.className = "mv-pane-tools";
|
||||
const mkBtn = (label: string, title: string, fn: () => void) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "mv-tool";
|
||||
b.title = title;
|
||||
b.textContent = label;
|
||||
b.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
});
|
||||
return b;
|
||||
};
|
||||
// Order: disconnect (⏻) leftmost, then split-right / split-down / close.
|
||||
if (leaf.sessionId) {
|
||||
const off = mkBtn("⏻", "Disconnect", () => onDisconnect(leaf.sessionId!));
|
||||
off.classList.add("mv-tool-off");
|
||||
tools.appendChild(off);
|
||||
}
|
||||
tools.append(
|
||||
mkBtn("▥", "Split right", () => splitFocused(leaf.id, "row")),
|
||||
mkBtn("▤", "Split down", () => splitFocused(leaf.id, "col")),
|
||||
mkBtn("✕", "Close pane", () => closePane(leaf.id)),
|
||||
);
|
||||
|
||||
pane.append(host, tools);
|
||||
pane.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
focusLeaf(leaf.id);
|
||||
// Drag a pane by its title bar (not its buttons or the terminal body).
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest(".attach-header") && !t.closest("button, a, .mv-tool")) {
|
||||
runDrag({ kind: "pane", leafId: leaf.id }, e, { deferred: true });
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
return { el: pane, host, sessionId: leaf.sessionId };
|
||||
}
|
||||
|
||||
function wireDivider(
|
||||
divider: HTMLElement,
|
||||
split: Extract<Node, { kind: "split" }>,
|
||||
slotA: HTMLElement,
|
||||
slotB: HTMLElement,
|
||||
container: HTMLElement,
|
||||
): void {
|
||||
divider.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
divider.classList.add("is-dragging");
|
||||
divider.setPointerCapture(e.pointerId);
|
||||
const rect = container.getBoundingClientRect();
|
||||
const move = (ev: PointerEvent) => {
|
||||
const frac =
|
||||
split.dir === "row"
|
||||
? (ev.clientX - rect.left) / rect.width
|
||||
: (ev.clientY - rect.top) / rect.height;
|
||||
split.ratio = Math.max(0.1, Math.min(0.9, frac));
|
||||
slotA.style.flexGrow = String(split.ratio);
|
||||
slotB.style.flexGrow = String(1 - split.ratio);
|
||||
};
|
||||
const up = (ev: PointerEvent) => {
|
||||
divider.classList.remove("is-dragging");
|
||||
try {
|
||||
divider.releasePointerCapture(ev.pointerId);
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
divider.removeEventListener("pointermove", move);
|
||||
divider.removeEventListener("pointerup", up);
|
||||
};
|
||||
divider.addEventListener("pointermove", move);
|
||||
divider.addEventListener("pointerup", up);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the tree DOM, collecting each session leaf's mount host. */
|
||||
function buildTree(node: Node, hosts: { id: string; host: HTMLElement; info?: AttachInfo }[]): HTMLElement {
|
||||
if (node.kind === "leaf") {
|
||||
const pane = buildPane(node);
|
||||
if (node.sessionId) {
|
||||
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
|
||||
}
|
||||
return pane.el;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.className = `mv-split ${node.dir}`;
|
||||
const slotA = document.createElement("div");
|
||||
slotA.className = "mv-slot";
|
||||
slotA.style.cssText = `flex:${node.ratio} 1 0`;
|
||||
const divider = document.createElement("div");
|
||||
divider.className = "mv-divider";
|
||||
const slotB = document.createElement("div");
|
||||
slotB.className = "mv-slot";
|
||||
slotB.style.cssText = `flex:${1 - node.ratio} 1 0`;
|
||||
slotA.appendChild(buildTree(node.a, hosts));
|
||||
slotB.appendChild(buildTree(node.b, hosts));
|
||||
container.append(slotA, divider, slotB);
|
||||
wireDivider(divider, node, slotA, slotB, container);
|
||||
return container;
|
||||
}
|
||||
|
||||
// ---- tab bar ----
|
||||
|
||||
function renderTabs(): void {
|
||||
tabBar.replaceChildren();
|
||||
for (const t of layout.tabs) {
|
||||
const tab = document.createElement("button");
|
||||
tab.type = "button";
|
||||
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
|
||||
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 (layout.activeTabId !== t.id) {
|
||||
layout.activeTabId = t.id;
|
||||
render();
|
||||
}
|
||||
});
|
||||
if (layout.tabs.length > 1) {
|
||||
const close = document.createElement("span");
|
||||
close.className = "mv-tab-close";
|
||||
close.textContent = "✕";
|
||||
close.title = "Close tab";
|
||||
close.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(layout, t.id);
|
||||
render();
|
||||
});
|
||||
tab.appendChild(close);
|
||||
}
|
||||
tabBar.appendChild(tab);
|
||||
}
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "mv-tab-add";
|
||||
add.textContent = "+";
|
||||
add.title = "New tab";
|
||||
add.addEventListener("click", () => {
|
||||
addTab(layout);
|
||||
render();
|
||||
});
|
||||
tabBar.appendChild(add);
|
||||
}
|
||||
|
||||
// ---- mutations ----
|
||||
|
||||
function splitFocused(leafId: string, dir: Dir): void {
|
||||
const tab = activeTab(layout);
|
||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||
render();
|
||||
}
|
||||
|
||||
function closePane(leafId: string): void {
|
||||
const tab = activeTab(layout);
|
||||
removeLeaf(tab, leafId);
|
||||
if (tab.focusedLeafId === leafId) {
|
||||
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function openSession(sessionId: string): void {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root) {
|
||||
const id = splitLeaf(tab, "", sessionId, "row", true); // root from empty
|
||||
tab.focusedLeafId = id;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
// Fill the focused pane (VS Code style: open replaces the active group).
|
||||
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
|
||||
target.sessionId = sessionId;
|
||||
tab.focusedLeafId = target.id;
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- top-level render ----
|
||||
|
||||
function render(): void {
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
|
||||
if (!tab.root) {
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
canvas.appendChild(renderWelcome());
|
||||
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep focus pointing at a real leaf.
|
||||
if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
|
||||
}
|
||||
|
||||
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 })));
|
||||
syncFocus();
|
||||
}
|
||||
|
||||
function closeSession(sessionId: string): void {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (!loc) return;
|
||||
removeLeaf(loc.tab, loc.leaf.id);
|
||||
if (loc.tab.focusedLeafId === loc.leaf.id) {
|
||||
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- drag-to-dock (issue #32) ----
|
||||
// Drag a pane's title bar, or a session from the sidebar, onto another pane:
|
||||
// edge zones split that pane, centre swaps (pane source) or replaces (session
|
||||
// source). Sidebar drags move the session if it's already open elsewhere.
|
||||
type Zone = "left" | "right" | "top" | "bottom" | "center";
|
||||
type DragSource = { kind: "pane"; leafId: string } | { kind: "session"; sessionId: string };
|
||||
let drag: {
|
||||
source: DragSource;
|
||||
ghost: HTMLElement;
|
||||
overlay: HTMLElement | null;
|
||||
targetLeafId: string | null;
|
||||
zone: Zone | null;
|
||||
} | null = null;
|
||||
|
||||
function dragSessionId(src: DragSource): string | null {
|
||||
if (src.kind === "session") return src.sessionId;
|
||||
return findLeaf(activeTab(layout).root, src.leafId)?.sessionId ?? null;
|
||||
}
|
||||
/** The leaf in the ACTIVE tab the source occupies (skip it as a drop target). */
|
||||
function dragSelfLeaf(src: DragSource): string | null {
|
||||
if (src.kind === "pane") return src.leafId;
|
||||
const loc = locateSession(layout, src.sessionId);
|
||||
return loc && loc.tab.id === layout.activeTabId ? loc.leaf.id : null;
|
||||
}
|
||||
function dragLabel(src: DragSource): string {
|
||||
const sid = dragSessionId(src);
|
||||
if (!sid) return "empty pane";
|
||||
return infoFor(sid)?.name ?? sid;
|
||||
}
|
||||
|
||||
function makeGhost(src: DragSource): HTMLElement {
|
||||
const g = document.createElement("div");
|
||||
g.className = "mv-drag-ghost";
|
||||
g.textContent = `⠿ ${dragLabel(src)}`;
|
||||
document.body.appendChild(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
function runDrag(src: DragSource, e: PointerEvent, opts: { deferred: boolean }): void {
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
let started = !opts.deferred;
|
||||
if (started) drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null };
|
||||
const place = (ev: PointerEvent) => {
|
||||
if (drag) {
|
||||
drag.ghost.style.left = `${ev.clientX + 12}px`;
|
||||
drag.ghost.style.top = `${ev.clientY + 12}px`;
|
||||
}
|
||||
};
|
||||
if (started) place(e);
|
||||
const move = (ev: PointerEvent) => {
|
||||
if (!started) {
|
||||
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return;
|
||||
started = true;
|
||||
document.body.style.userSelect = "none";
|
||||
drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null };
|
||||
}
|
||||
place(ev);
|
||||
updateDock(ev);
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (!started) return;
|
||||
document.body.style.userSelect = "";
|
||||
suppressNextClick();
|
||||
finishDrag();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
/** Eat the click that follows a real drag (so a dragged sidebar row doesn't
|
||||
* also navigate, and a dragged pane title doesn't trigger anything). */
|
||||
function suppressNextClick(): void {
|
||||
const kill = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.removeEventListener("click", kill, true);
|
||||
};
|
||||
window.addEventListener("click", kill, true);
|
||||
}
|
||||
|
||||
function paneUnder(x: number, y: number): HTMLElement | null {
|
||||
for (const node of document.elementsFromPoint(x, y)) {
|
||||
const pane = (node as HTMLElement).closest?.(".mv-pane") as HTMLElement | null;
|
||||
if (pane && canvas.contains(pane)) return pane;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearOverlay(): void {
|
||||
drag?.overlay?.remove();
|
||||
if (drag) drag.overlay = null;
|
||||
}
|
||||
|
||||
function updateDock(ev: PointerEvent): void {
|
||||
if (!drag) return;
|
||||
clearOverlay();
|
||||
const pane = paneUnder(ev.clientX, ev.clientY);
|
||||
const self = dragSelfLeaf(drag.source);
|
||||
if (!pane || pane.dataset.leafId === self) {
|
||||
drag.targetLeafId = null;
|
||||
drag.zone = null;
|
||||
return;
|
||||
}
|
||||
const r = pane.getBoundingClientRect();
|
||||
const fx = (ev.clientX - r.left) / r.width;
|
||||
const fy = (ev.clientY - r.top) / r.height;
|
||||
const edges = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
|
||||
const nearest = (Object.keys(edges) as (keyof typeof edges)[]).reduce((m, k) =>
|
||||
edges[k] < edges[m] ? k : m,
|
||||
);
|
||||
const zone: Zone = edges[nearest] < 0.28 ? nearest : "center";
|
||||
drag.targetLeafId = pane.dataset.leafId!;
|
||||
drag.zone = zone;
|
||||
|
||||
const ov = document.createElement("div");
|
||||
ov.className = "mv-dock-overlay";
|
||||
const hot = document.createElement("div");
|
||||
hot.className = "mv-dock-zone";
|
||||
const css: Record<Zone, string> = {
|
||||
left: "left:0;top:0;width:50%;height:100%",
|
||||
right: "left:50%;top:0;width:50%;height:100%",
|
||||
top: "left:0;top:0;width:100%;height:50%",
|
||||
bottom: "left:0;top:50%;width:100%;height:50%",
|
||||
center: "inset:14%",
|
||||
};
|
||||
hot.style.cssText = "position:absolute;" + css[zone];
|
||||
ov.appendChild(hot);
|
||||
pane.appendChild(ov);
|
||||
drag.overlay = ov;
|
||||
}
|
||||
|
||||
function finishDrag(): void {
|
||||
if (!drag) return;
|
||||
const { source, targetLeafId, zone, ghost } = drag;
|
||||
ghost.remove();
|
||||
clearOverlay();
|
||||
drag = null;
|
||||
if (!targetLeafId || !zone) return;
|
||||
|
||||
const tab = activeTab(layout);
|
||||
const self = dragSelfLeaf(source);
|
||||
if (targetLeafId === self) return; // dropped on its own pane
|
||||
const target = findLeaf(tab.root, targetLeafId);
|
||||
if (!target) return;
|
||||
const sessionId = dragSessionId(source);
|
||||
|
||||
if (zone === "center") {
|
||||
if (source.kind === "pane" && self) {
|
||||
const dragged = findLeaf(tab.root, self)!;
|
||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||
tab.focusedLeafId = target.id;
|
||||
} else if (sessionId) {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
||||
const t2 = findLeaf(tab.root, targetLeafId);
|
||||
if (t2) {
|
||||
t2.sessionId = sessionId;
|
||||
tab.focusedLeafId = t2.id;
|
||||
}
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// edge zone → split the target, placing the source beside it
|
||||
const dir: Dir = zone === "left" || zone === "right" ? "row" : "col";
|
||||
const after = zone === "right" || zone === "bottom";
|
||||
const loc = sessionId ? locateSession(layout, sessionId) : null;
|
||||
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
||||
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
||||
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
||||
render();
|
||||
}
|
||||
|
||||
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
|
||||
runDrag({ kind: "session", sessionId }, e, { deferred: true });
|
||||
}
|
||||
|
||||
return {
|
||||
el,
|
||||
openSession,
|
||||
closeSession,
|
||||
beginSessionDrag,
|
||||
render,
|
||||
focusedSessionId: focusedLeafSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Stable per-session accent colour, used to map a sidebar row to the pane
|
||||
// showing that session (issue #33). Hash the session id into a fixed OKLCH
|
||||
// palette tuned to the maestro tokens; collisions are tolerated when there are
|
||||
// more open sessions than hues — the colour is a hint, not an identity.
|
||||
|
||||
import type { Uuid } from "./types.js";
|
||||
|
||||
const PALETTE = [
|
||||
"oklch(0.80 0.13 152)", // green (the house accent)
|
||||
"oklch(0.80 0.12 90)", // amber
|
||||
"oklch(0.76 0.13 30)", // coral
|
||||
"oklch(0.78 0.11 210)", // teal
|
||||
"oklch(0.74 0.13 265)", // blue
|
||||
"oklch(0.76 0.14 320)", // magenta
|
||||
"oklch(0.80 0.11 60)", // gold
|
||||
"oklch(0.78 0.12 185)", // cyan
|
||||
];
|
||||
|
||||
export function sessionColor(id: Uuid): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return PALETTE[h % PALETTE.length];
|
||||
}
|
||||
@@ -1899,3 +1899,321 @@ textarea {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
multi-view (#30 splits, #31 tabs, #33 highlights)
|
||||
Tabs of nested split trees; each session leaf hosts a live .attach. The
|
||||
focused pane reads at full strength, the rest recede — and the sidebar row
|
||||
for the focused session matches it (loud), open-elsewhere rows stay quiet.
|
||||
========================================================================= */
|
||||
.mv {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* tab bar */
|
||||
.mv-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
height: var(--bar-h);
|
||||
padding: 0 var(--sp-2);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: none;
|
||||
}
|
||||
.mv-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-3);
|
||||
align-self: center;
|
||||
height: calc(var(--bar-h) - 8px);
|
||||
border-radius: var(--r-1);
|
||||
font: 500 var(--fs-12) / 1 var(--font-mono);
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mv-tab:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.mv-tab.is-active {
|
||||
background: var(--surface-3);
|
||||
color: var(--ink);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.mv-tab-close {
|
||||
opacity: 0.5;
|
||||
font-size: var(--fs-13);
|
||||
line-height: 1;
|
||||
}
|
||||
.mv-tab-close:hover {
|
||||
opacity: 1;
|
||||
color: var(--danger);
|
||||
}
|
||||
.mv-tab-add {
|
||||
align-self: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--r-1);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-15);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mv-tab-add:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* canvas + welcome */
|
||||
.mv-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.mv-canvas > .mv-split,
|
||||
.mv-canvas > .mv-pane {
|
||||
position: absolute;
|
||||
inset: var(--sp-2);
|
||||
}
|
||||
.mv-canvas > .content-welcome {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
/* splits + dividers */
|
||||
.mv-split {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.mv-split.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.mv-split.col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.mv-split > .mv-slot {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.mv-split > .mv-slot > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.mv-divider {
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.mv-split.row > .mv-divider {
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.mv-split.col > .mv-divider {
|
||||
height: 6px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
.mv-divider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
background: var(--border);
|
||||
}
|
||||
.mv-split.row > .mv-divider::after {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
}
|
||||
.mv-split.col > .mv-divider::after {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
.mv-divider:hover::after,
|
||||
.mv-divider.is-dragging::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* a single pane = wrapper around a live .attach (or an empty placeholder) */
|
||||
.mv-pane {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
}
|
||||
.mv-pane-host {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.mv-pane-host > .attach {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
/* per-session colour strip — maps a pane to its sidebar row */
|
||||
.mv-pane::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 2px;
|
||||
background: var(--mv-color, transparent);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* focused pane: accent ring + glow, full strength */
|
||||
.mv-pane.is-focused {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
|
||||
}
|
||||
/* unfocused panes recede so the eye finds the live one */
|
||||
.mv-pane:not(.is-focused) {
|
||||
border-color: color-mix(in oklab, var(--border) 70%, transparent);
|
||||
}
|
||||
.mv-pane:not(.is-focused) .attach-body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.mv-pane:not(.is-focused) .attach-header {
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* per-pane back button is meaningless in multi-view (use Close pane instead) */
|
||||
.mv-pane .attach-header .back {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mv-pane-empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-4);
|
||||
color: var(--faint);
|
||||
font: var(--fs-12) / 1.5 var(--font-mono);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
|
||||
.mv-pane-tools {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 4px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.mv-pane .attach-header .disconnect {
|
||||
display: none;
|
||||
}
|
||||
.mv-tool-off:hover {
|
||||
background: var(--danger-tint);
|
||||
color: var(--danger);
|
||||
}
|
||||
.mv-pane:hover .mv-pane-tools {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* The tools share the header's right corner with the connection-state label, so
|
||||
hide that label while hovering (tools showing) — no overlap. */
|
||||
.mv-pane:hover .attach-state {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* "connected" is noise — a streaming terminal already shows it's connected. Only
|
||||
the transient/meaningful states surface (connecting/error); a closed session
|
||||
auto-closes its pane, so "closed" is rarely seen. */
|
||||
.attach-state[data-state="open"] {
|
||||
display: none;
|
||||
}
|
||||
.mv-tool {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--r-1);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-12);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mv-tool:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* drag-to-dock (issue #32): a cursor-following ghost + the target pane's drop
|
||||
zone highlight. */
|
||||
.mv-drag-ghost {
|
||||
position: fixed;
|
||||
z-index: var(--z-toast);
|
||||
pointer-events: none;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--r-2);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--ink);
|
||||
font: 600 var(--fs-12) / 1.4 var(--font-mono);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.mv-dock-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mv-dock-zone {
|
||||
background: color-mix(in oklab, var(--accent) 30%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
/* sidebar two-tier highlight + colour dot (issue #33).
|
||||
.is-active = focused pane (loud, from existing rule); .is-open = open
|
||||
elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
|
||||
.tree-leaf.is-open {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
.tree-leaf.is-open::after,
|
||||
.tree-leaf.is-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
top: 50%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: -3.5px;
|
||||
border-radius: 50%;
|
||||
background: var(--mv-color, var(--accent));
|
||||
pointer-events: none;
|
||||
}
|
||||
.tree-leaf.is-open::after {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tree-leaf.is-active::after {
|
||||
box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user