Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
328711cade | ||
|
|
b0504eb736 | ||
|
|
6075a177f5 |
@@ -87,10 +87,13 @@ pub struct App {
|
|||||||
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
||||||
/// mouse) is covered without each one remembering to report it.
|
/// mouse) is covered without each one remembering to report it.
|
||||||
focused_session: Option<Uuid>,
|
focused_session: Option<Uuid>,
|
||||||
/// A sidebar highlight waiting out its debounce before it becomes a
|
/// A sidebar highlight waiting for the pane's minimum gap before it
|
||||||
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must
|
/// becomes a preview (`docs/PREVIEW.md`). Walking the list with `j` held
|
||||||
/// not open an attach per row it passes over.
|
/// down must not open an attach per row it passes over.
|
||||||
pending_preview: Option<(Uuid, std::time::Instant)>,
|
pending_preview: Option<(Uuid, std::time::Instant)>,
|
||||||
|
/// When the pane last swapped what it previews. `None` until the first
|
||||||
|
/// one, which is why a highlight landing on a still sidebar shows at once.
|
||||||
|
last_preview_at: Option<std::time::Instant>,
|
||||||
/// Sessions attached only because they were previewed, oldest first. Kept
|
/// Sessions attached only because they were previewed, oldest first. Kept
|
||||||
/// connected past the preview so stepping back onto a row is instant, and
|
/// connected past the preview so stepping back onto a row is instant, and
|
||||||
/// bounded so a long walk doesn't leave a socket per session behind.
|
/// bounded so a long walk doesn't leave a socket per session behind.
|
||||||
@@ -125,10 +128,11 @@ struct FrameKey {
|
|||||||
/// else changed.
|
/// else changed.
|
||||||
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
|
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
/// How long a highlight has to stand still before the pane previews it
|
/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
|
||||||
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't
|
/// highlight to land shows at once; this only bounds how often the pane may
|
||||||
/// attach to every row it crosses, short enough to read as immediate.
|
/// follow after that, so a fast walk down the list attaches to the row it
|
||||||
const PREVIEW_DELAY: Duration = Duration::from_millis(120);
|
/// started on and the row it stopped on rather than every row between.
|
||||||
|
const PREVIEW_GAP: Duration = Duration::from_millis(120);
|
||||||
|
|
||||||
/// How many preview-only attaches to keep connected behind the current one.
|
/// How many preview-only attaches to keep connected behind the current one.
|
||||||
const PREVIEW_BUDGET: usize = 3;
|
const PREVIEW_BUDGET: usize = 3;
|
||||||
@@ -213,6 +217,7 @@ impl App {
|
|||||||
clicks: ClickTracker::default(),
|
clicks: ClickTracker::default(),
|
||||||
focused_session: None,
|
focused_session: None,
|
||||||
pending_preview: None,
|
pending_preview: None,
|
||||||
|
last_preview_at: None,
|
||||||
preview_lru: Vec::new(),
|
preview_lru: Vec::new(),
|
||||||
last_frame: None,
|
last_frame: None,
|
||||||
last_drawn_at: std::time::Instant::now(),
|
last_drawn_at: std::time::Instant::now(),
|
||||||
@@ -526,9 +531,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute what the pane should be previewing. A session highlighted in
|
/// Recompute what the pane should be previewing. A session highlighted in
|
||||||
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a
|
/// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the
|
||||||
/// group row, the pane holding the keyboard, or the highlight already
|
/// last swap — immediately, for a highlight landing on a still list.
|
||||||
/// sitting on the attached session — clears the preview at once.
|
/// Anything else — a group row, the pane holding the keyboard, or the
|
||||||
|
/// highlight already sitting on the attached session — clears at once.
|
||||||
fn refresh_preview(&mut self) {
|
fn refresh_preview(&mut self) {
|
||||||
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
||||||
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
||||||
@@ -537,7 +543,12 @@ impl App {
|
|||||||
match wanted {
|
match wanted {
|
||||||
Some(sid) if self.state.active_session() != Some(sid) => {
|
Some(sid) if self.state.active_session() != Some(sid) => {
|
||||||
if self.state.preview != Some(sid) {
|
if self.state.preview != Some(sid) {
|
||||||
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY));
|
let now = std::time::Instant::now();
|
||||||
|
let due = match self.last_preview_at {
|
||||||
|
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
|
||||||
|
_ => now,
|
||||||
|
};
|
||||||
|
self.pending_preview = Some((sid, due));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -547,8 +558,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The debounce elapsed: show the highlighted session, attaching it if
|
/// The pane is free to follow: show the highlighted session, attaching it
|
||||||
/// nothing already is.
|
/// if nothing already is.
|
||||||
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
||||||
let Some((sid, due)) = self.pending_preview else {
|
let Some((sid, due)) = self.pending_preview else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -560,6 +571,7 @@ impl App {
|
|||||||
if self.state.preview == Some(sid) {
|
if self.state.preview == Some(sid) {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
self.last_preview_at = Some(now);
|
||||||
self.state.preview = Some(sid);
|
self.state.preview = Some(sid);
|
||||||
// Set outside the input path, so nothing else marks the frame stale.
|
// Set outside the input path, so nothing else marks the frame stale.
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
@@ -1671,24 +1683,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn after_debounce() -> std::time::Instant {
|
fn after_gap() -> std::time::Instant {
|
||||||
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
|
std::time::Instant::now() + PREVIEW_GAP + Duration::from_millis(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
|
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
|
||||||
/// tab taking it on.
|
/// tab taking it on.
|
||||||
#[test]
|
#[test]
|
||||||
fn highlighting_a_session_previews_it_once_the_debounce_elapses() {
|
fn highlighting_a_session_previews_it_without_waiting() {
|
||||||
let a = Uuid::from_u128(1);
|
let a = Uuid::from_u128(1);
|
||||||
let mut app = app_with_sessions(&[a]);
|
let mut app = app_with_sessions(&[a]);
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
|
|
||||||
// Nothing yet: the highlight has to stand still first.
|
// The list was still, so the pane follows on the next poll — no gap to
|
||||||
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]);
|
// wait out first (docs/PREVIEW.md).
|
||||||
assert_eq!(app.state.preview, None);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.poll_due_preview_at(after_debounce()),
|
app.poll_due_preview_at(std::time::Instant::now()),
|
||||||
vec![Effect::Attach(a)]
|
vec![Effect::Attach(a)]
|
||||||
);
|
);
|
||||||
assert_eq!(app.state.preview, Some(a));
|
assert_eq!(app.state.preview, Some(a));
|
||||||
@@ -1697,6 +1707,28 @@ mod tests {
|
|||||||
assert_eq!(app.state.active_tab().session(), None);
|
assert_eq!(app.state.active_tab().session(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The gap only bounds the *second* swap, so a burst costs two mounts.
|
||||||
|
#[test]
|
||||||
|
fn a_second_move_inside_the_gap_waits_for_it() {
|
||||||
|
let a = Uuid::from_u128(1);
|
||||||
|
let b = Uuid::from_u128(2);
|
||||||
|
let mut app = app_with_sessions(&[a, b]);
|
||||||
|
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]);
|
||||||
|
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
assert_eq!(app.poll_due_preview_at(now), vec![]);
|
||||||
|
assert_eq!(app.state.preview, Some(a));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.poll_due_preview_at(after_gap()),
|
||||||
|
vec![Effect::Attach(b)]
|
||||||
|
);
|
||||||
|
assert_eq!(app.state.preview, Some(b));
|
||||||
|
}
|
||||||
|
|
||||||
/// A fast walk down the list attaches to where it stopped, not to every
|
/// A fast walk down the list attaches to where it stopped, not to every
|
||||||
/// row it crossed.
|
/// row it crossed.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1707,7 +1739,7 @@ mod tests {
|
|||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
|
|
||||||
let effects = app.poll_due_preview_at(after_debounce());
|
let effects = app.poll_due_preview_at(after_gap());
|
||||||
assert_eq!(effects, vec![Effect::Attach(b)]);
|
assert_eq!(effects, vec![Effect::Attach(b)]);
|
||||||
assert_eq!(app.state.preview, Some(b));
|
assert_eq!(app.state.preview, Some(b));
|
||||||
}
|
}
|
||||||
@@ -1723,7 +1755,7 @@ mod tests {
|
|||||||
|
|
||||||
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
||||||
app.refresh_preview();
|
app.refresh_preview();
|
||||||
app.poll_due_preview_at(after_debounce());
|
app.poll_due_preview_at(after_gap());
|
||||||
assert_eq!(app.state.preview, Some(b));
|
assert_eq!(app.state.preview, Some(b));
|
||||||
|
|
||||||
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
|
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
|
||||||
@@ -1743,7 +1775,7 @@ mod tests {
|
|||||||
app.state.focus = Focus::Sidebar;
|
app.state.focus = Focus::Sidebar;
|
||||||
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
||||||
app.refresh_preview();
|
app.refresh_preview();
|
||||||
app.poll_due_preview_at(after_debounce());
|
app.poll_due_preview_at(after_gap());
|
||||||
|
|
||||||
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
|
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
|
||||||
assert_eq!(app.state.preview, None);
|
assert_eq!(app.state.preview, None);
|
||||||
@@ -1756,7 +1788,7 @@ mod tests {
|
|||||||
let a = Uuid::from_u128(1);
|
let a = Uuid::from_u128(1);
|
||||||
let mut app = app_with_sessions(&[a]);
|
let mut app = app_with_sessions(&[a]);
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
app.poll_due_preview_at(after_debounce());
|
app.poll_due_preview_at(after_gap());
|
||||||
// Stand in for the attach the effect would have opened.
|
// Stand in for the attach the effect would have opened.
|
||||||
app.emulators.insert(a, fake_handle());
|
app.emulators.insert(a, fake_handle());
|
||||||
|
|
||||||
@@ -1775,10 +1807,13 @@ mod tests {
|
|||||||
.map(Uuid::from_u128)
|
.map(Uuid::from_u128)
|
||||||
.collect();
|
.collect();
|
||||||
let mut app = app_with_sessions(&ids);
|
let mut app = app_with_sessions(&ids);
|
||||||
|
// Each row is previewed in its own gap window, so all of them land.
|
||||||
|
let mut clock = std::time::Instant::now();
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
|
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
|
||||||
app.refresh_preview();
|
app.refresh_preview();
|
||||||
app.poll_due_preview_at(after_debounce());
|
clock += PREVIEW_GAP + Duration::from_millis(1);
|
||||||
|
app.poll_due_preview_at(clock);
|
||||||
app.emulators.insert(*id, fake_handle());
|
app.emulators.insert(*id, fake_handle());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -50,15 +50,19 @@ Nothing moves a second time on commit.
|
|||||||
|
|
||||||
## Timing
|
## Timing
|
||||||
|
|
||||||
A preview follows the highlight closely enough to be usable as a way to look
|
**A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` —
|
||||||
around, and lags a pointer enough not to flash rows crossed on the way
|
shows it on the next frame, and so does leaving every row. There is no
|
||||||
somewhere else.
|
settling period in front of the first preview, because that period would sit
|
||||||
|
in front of the one preview the operator actually meant.
|
||||||
|
|
||||||
| Trigger | Delay |
|
What is bounded is how *often* the pane may swap: at most once per 120 ms.
|
||||||
|---|---|
|
A burst inside one window — `j` held down, or the pointer sweeping the length
|
||||||
| Keyboard step (`j`/`k`, arrows) | 60 ms |
|
of the sidebar — shows the row it started on, then the row it ended on, and
|
||||||
| Pointer hover (web, mouse only — never touch) | 150 ms |
|
none of the rows in between. So a walk costs a couple of pane mounts rather
|
||||||
| Clearing (highlight left every session) | same delay as the trigger that set it |
|
than one per row, without the operator ever waiting on one.
|
||||||
|
|
||||||
|
The highlight itself is not bounded by any of this: the row lights up on the
|
||||||
|
same frame as the input, whatever the pane behind it is doing.
|
||||||
|
|
||||||
Touch has no hover, and a tap already opens a session, so touch never previews.
|
Touch has no hover, and a tap already opens a session, so touch never previews.
|
||||||
|
|
||||||
|
|||||||
+15
-7
@@ -5,6 +5,7 @@ import "@xterm/xterm/css/xterm.css";
|
|||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
import { attachWsUrl } from "./routes.js";
|
||||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
||||||
|
import { CommandSet, defineCommands } from "./commands.js";
|
||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||||
@@ -13,6 +14,15 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
|||||||
import type { ClientControl, Uuid } from "./types.js";
|
import type { ClientControl, Uuid } from "./types.js";
|
||||||
import { WebglPolicy } from "./webglRenderer.js";
|
import { WebglPolicy } from "./webglRenderer.js";
|
||||||
|
|
||||||
|
// The pane's half of the command registry (#148): plain Escape hands the
|
||||||
|
// keyboard back to the session list, the pair to the list's Tab.
|
||||||
|
const paneKeys = new CommandSet(
|
||||||
|
defineCommands({
|
||||||
|
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
|
||||||
|
}),
|
||||||
|
{ preventDefault: false },
|
||||||
|
);
|
||||||
|
|
||||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||||
* rationed rather than handed to every connection. */
|
* rationed rather than handed to every connection. */
|
||||||
const webglPolicy = new WebglPolicy();
|
const webglPolicy = new WebglPolicy();
|
||||||
@@ -56,8 +66,6 @@ export interface AttachOptions {
|
|||||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** What the header shows about the session (name / command). Optional — falls
|
/** What the header shows about the session (name / command). Optional — falls
|
||||||
* back to a short session id when the session isn't known yet. */
|
* back to a short session id when the session isn't known yet. */
|
||||||
export interface AttachInfo {
|
export interface AttachInfo {
|
||||||
@@ -210,12 +218,12 @@ export function createAttachConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plain Escape hands the keyboard back to the session list instead of the
|
// Plain Escape hands the keyboard back to the session list instead of the
|
||||||
// shell/app. `false` tells xterm to skip its own handling of this event.
|
// shell/app. `false` tells xterm to skip its own handling of this event —
|
||||||
|
// which is also why the set doesn't `preventDefault`: swallowing here is
|
||||||
|
// xterm's return value, not the browser's.
|
||||||
term.attachCustomKeyEventHandler((e) => {
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
if (e.type !== "keydown" || e.key !== "Escape") return true;
|
if (e.type !== "keydown") return true;
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
|
return !paneKeys.handle(e);
|
||||||
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||||
|
|||||||
+100
-30
@@ -2,7 +2,8 @@ import { escapeHtml } from "./html.js";
|
|||||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
import { openForceKillDialog } from "./killDialog.js";
|
import { openForceKillDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { isTypingTarget, toggleHelp } from "./help.js";
|
import { toggleHelp } from "./help.js";
|
||||||
|
import { CommandSet, defineCommands } from "./commands.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { InTheWay, Session } from "./types.js";
|
import type { InTheWay, Session } from "./types.js";
|
||||||
import {
|
import {
|
||||||
@@ -164,6 +165,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The row outlives the snapshot it was built from: a refresh that changes
|
||||||
|
// nothing structural patches it in place rather than replacing it, so the
|
||||||
|
// handlers below have to read the current session, not the one captured here.
|
||||||
|
let cur = node.session;
|
||||||
const s = node.session;
|
const s = node.session;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const row = document.createElement("a");
|
const row = document.createElement("a");
|
||||||
@@ -197,33 +202,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
similarBtn.addEventListener("click", (ev) => {
|
similarBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||||
});
|
});
|
||||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||||
killBtn.addEventListener("click", (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void requestKill(s, name);
|
void requestKill(cur, sessionName(cur));
|
||||||
});
|
});
|
||||||
row.addEventListener("click", () => {
|
row.addEventListener("click", () => {
|
||||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||||
});
|
});
|
||||||
row.addEventListener("contextmenu", (ev) => {
|
row.addEventListener("contextmenu", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
openContextMenu(ev.clientX, ev.clientY, [
|
openContextMenu(ev.clientX, ev.clientY, [
|
||||||
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||||
{
|
{
|
||||||
label: "Spawn similar",
|
label: "Spawn similar",
|
||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Kill session",
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||||
},
|
},
|
||||||
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
leafPatches.set(row, (next) => {
|
||||||
|
cur = next;
|
||||||
|
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||||
|
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||||
|
row.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||||
|
);
|
||||||
|
const rail = row.querySelector(".activity-rail");
|
||||||
|
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||||
|
const meta = row.querySelector(".card-line2");
|
||||||
|
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
||||||
|
});
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
||||||
|
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
||||||
|
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
||||||
|
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything about the tree that a patch cannot fix up.
|
||||||
|
*
|
||||||
|
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
||||||
|
* a couple of times a minute per live session — and rebuilding the sidebar for
|
||||||
|
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
||||||
|
* signature is unchanged are patched into the standing rows instead; anything
|
||||||
|
* that moves, renames, appears or dies changes the signature and rebuilds.
|
||||||
|
*/
|
||||||
|
function treeSignature(node: TreeNode): string {
|
||||||
|
if (node.kind === "folder") {
|
||||||
|
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
||||||
|
if (node.name && collapsed.has(node.path)) return head;
|
||||||
|
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
||||||
|
}
|
||||||
|
const s = node.session;
|
||||||
|
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||||
@@ -368,7 +414,7 @@ export interface SidebarOptions {
|
|||||||
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
* 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
|
* `source` is there so a mouse sweep can be debounced differently from a
|
||||||
* keyboard step. */
|
* keyboard step. */
|
||||||
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
|
onPreview?: (sessionId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||||
@@ -547,7 +593,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
navKey = next.dataset.navKey ?? null;
|
navKey = next.dataset.navKey ?? null;
|
||||||
applyNav();
|
applyNav();
|
||||||
next.scrollIntoView({ block: "nearest" });
|
next.scrollIntoView({ block: "nearest" });
|
||||||
opts.onPreview?.(sessionOf(navKey), "key");
|
opts.onPreview?.(sessionOf(navKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||||
@@ -578,7 +624,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
const setHovered = (id: string | null) => {
|
const setHovered = (id: string | null) => {
|
||||||
if (hovered === id) return;
|
if (hovered === id) return;
|
||||||
hovered = id;
|
hovered = id;
|
||||||
opts.onPreview?.(id, "hover");
|
opts.onPreview?.(id);
|
||||||
};
|
};
|
||||||
treeHost.addEventListener("pointerover", (e) => {
|
treeHost.addEventListener("pointerover", (e) => {
|
||||||
if (e.pointerType === "touch") return;
|
if (e.pointerType === "touch") return;
|
||||||
@@ -595,7 +641,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
treeHost.tabIndex = -1;
|
treeHost.tabIndex = -1;
|
||||||
// The keyboard walk's preview belongs to the list having the keyboard: once
|
// 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.
|
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
|
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
||||||
|
|
||||||
/** The session behind the highlighted row, if the highlight is on a card. */
|
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||||
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||||
@@ -616,24 +662,33 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
treeHost.addEventListener("keydown", (e) => {
|
// The session list's half of the command registry (#148). One set per
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
|
||||||
// The inline rename field lives inside a row and owns its own keys.
|
// labels and chords still come from the one registry.
|
||||||
if (isTypingTarget(e.target)) return;
|
const listKeys = new CommandSet(
|
||||||
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
defineCommands({
|
||||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
"list.next": () => moveNav(1),
|
||||||
else if (e.key === "Enter") activateNav();
|
"list.prev": () => moveNav(-1),
|
||||||
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
"list.activate": () => activateNav(),
|
||||||
else if (e.key === "c") toggleHighlightedFolder();
|
"list.toggleFolder": () => toggleHighlightedFolder(),
|
||||||
else if (e.key === "x") {
|
"list.rename": () => {
|
||||||
const hit = highlightedSession();
|
const hit = highlightedSession();
|
||||||
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
if (hit) beginRename(hit.row, hit.session);
|
||||||
} else if (e.key === "r") {
|
},
|
||||||
const hit = highlightedSession();
|
"list.kill": () => {
|
||||||
if (hit) beginRename(hit.row, hit.session);
|
const hit = highlightedSession();
|
||||||
} else return;
|
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||||
e.preventDefault();
|
},
|
||||||
});
|
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The inline rename field lives inside a row and owns its own keys; the set's
|
||||||
|
// plain-key rule keeps this walk out of it.
|
||||||
|
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
|
||||||
|
|
||||||
|
/** Signature of what is currently on screen — see `treeSignature`. */
|
||||||
|
let renderedSignature: string | null = null;
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
// Rebuilding the tree now would blow away the field being typed into.
|
// Rebuilding the tree now would blow away the field being typed into.
|
||||||
@@ -641,6 +696,15 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
const state = store.snapshot();
|
const state = store.snapshot();
|
||||||
const sessions = [...state.sessions.values()];
|
const sessions = [...state.sessions.values()];
|
||||||
const tree = buildTree(sessions, homeDir, filter);
|
const tree = buildTree(sessions, homeDir, filter);
|
||||||
|
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||||
|
if (signature === renderedSignature) {
|
||||||
|
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||||
|
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||||
|
if (s) leafPatches.get(row)?.(s);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderedSignature = signature;
|
||||||
treeHost.replaceChildren();
|
treeHost.replaceChildren();
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
treeHost.appendChild(
|
treeHost.appendChild(
|
||||||
@@ -664,7 +728,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
/* absolute paths are a fine fallback */
|
/* absolute paths are a fine fallback */
|
||||||
});
|
});
|
||||||
|
|
||||||
const rerenderHandler = () => refresh();
|
// Dispatched when something outside the store changed what the tree should
|
||||||
|
// look like — a folder collapsed, a rename ended — none of which the store
|
||||||
|
// snapshot can show, so the signature check has to be bypassed.
|
||||||
|
const rerenderHandler = () => {
|
||||||
|
renderedSignature = null;
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||||
|
|
||||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { renderSidebar } from "./catalog.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
window.fetch = () => Promise.reject(new Error("offline"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: ID,
|
||||||
|
cwd: "/home/naps/tea/maestro",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A store whose updates actually reach subscribers, unlike the one-shot
|
||||||
|
* harness in catalog.test.ts. */
|
||||||
|
function liveStore(initial: Session[]) {
|
||||||
|
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
|
||||||
|
const listeners = new Set<CatalogListener>();
|
||||||
|
const store = {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
listeners.add(fn);
|
||||||
|
fn(state);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
const push = (sessions: Session[]) => {
|
||||||
|
state.sessions = new Map(sessions.map((s) => [s.id, s]));
|
||||||
|
for (const fn of listeners) fn(state);
|
||||||
|
};
|
||||||
|
return { store, push };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
|
||||||
|
|
||||||
|
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
|
||||||
|
it("patches an activity change into the standing row instead of rebuilding it", () => {
|
||||||
|
const { store, push } = liveStore([session({ activity: "Idle" })]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session({ activity: "AwaitingInput" })]);
|
||||||
|
|
||||||
|
// Same element: a rebuild under the pointer would drop `:hover` and restart
|
||||||
|
// the row's transitions, which is the flicker this avoids.
|
||||||
|
expect(rowOf(el)).toBe(before);
|
||||||
|
// …and the card still tells the truth about the session.
|
||||||
|
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
|
||||||
|
"awaiting-input",
|
||||||
|
);
|
||||||
|
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||||
|
"awaiting-input",
|
||||||
|
);
|
||||||
|
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds when the row set changes", () => {
|
||||||
|
const { store, push } = liveStore([session()]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
|
||||||
|
|
||||||
|
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||||
|
expect(rowOf(el)).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds when a session dies", () => {
|
||||||
|
const { store, push } = liveStore([session()]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session({ status: "Dead" })]);
|
||||||
|
|
||||||
|
expect(rowOf(el)).not.toBe(before);
|
||||||
|
expect(rowOf(el).dataset.status).toBe("dead");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds when a session is renamed", () => {
|
||||||
|
const { store, push } = liveStore([session()]);
|
||||||
|
const { el } = renderSidebar(store, {});
|
||||||
|
const before = rowOf(el);
|
||||||
|
|
||||||
|
push([session({ metadata: { name: "harmony" } })]);
|
||||||
|
|
||||||
|
expect(rowOf(el)).not.toBe(before);
|
||||||
|
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
COMMAND_SPECS,
|
||||||
|
CommandSet,
|
||||||
|
DEFAULT_LEADER,
|
||||||
|
canonicalizeKey,
|
||||||
|
defineCommands,
|
||||||
|
isTypingTarget,
|
||||||
|
keyCaps,
|
||||||
|
publishedCommands,
|
||||||
|
} from "./commands.js";
|
||||||
|
|
||||||
|
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||||
|
return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init });
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(html: string): HTMLElement {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.innerHTML = html;
|
||||||
|
return d.firstElementChild as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch on a target, so `isTypingTarget` sees something other than null. */
|
||||||
|
function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean {
|
||||||
|
const e = key(k, init);
|
||||||
|
Object.defineProperty(e, "target", { value: target });
|
||||||
|
return set.handle(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("command specs", () => {
|
||||||
|
it("has a unique id and at least one chord per command", () => {
|
||||||
|
const ids = COMMAND_SPECS.map((s) => s.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
for (const spec of COMMAND_SPECS) {
|
||||||
|
expect(spec.keys.length).toBeGreaterThan(0);
|
||||||
|
expect(spec.label.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds each chord once within a scope", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const spec of COMMAND_SPECS) {
|
||||||
|
for (const k of spec.keys) {
|
||||||
|
const slot = `${spec.scope}:${k}`;
|
||||||
|
expect(seen.has(slot)).toBe(false);
|
||||||
|
seen.add(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a handler for an id that isn't in the registry", () => {
|
||||||
|
expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries every spec field onto the defined command", () => {
|
||||||
|
const [cmd] = defineCommands({ "spawn.open": () => {} });
|
||||||
|
expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canonicalizeKey", () => {
|
||||||
|
it("spells keys the way the TUI's InputKey::canonical does", () => {
|
||||||
|
expect(canonicalizeKey(key("n"))).toBe("n");
|
||||||
|
expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N");
|
||||||
|
expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?");
|
||||||
|
expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a");
|
||||||
|
expect(canonicalizeKey(key("Escape"))).toBe("esc");
|
||||||
|
expect(canonicalizeKey(key("Enter"))).toBe("enter");
|
||||||
|
expect(canonicalizeKey(key("Tab"))).toBe("tab");
|
||||||
|
expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab");
|
||||||
|
expect(canonicalizeKey(key("ArrowDown"))).toBe("down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds a modified letter to lower case with an explicit shift-", () => {
|
||||||
|
expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
||||||
|
expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no canonical form for a modifier pressed alone", () => {
|
||||||
|
expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull();
|
||||||
|
expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keyCaps", () => {
|
||||||
|
it("renders one cap per modifier and key", () => {
|
||||||
|
expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]);
|
||||||
|
expect(keyCaps("enter")).toEqual(["Enter"]);
|
||||||
|
expect(keyCaps("j")).toEqual(["j"]);
|
||||||
|
expect(keyCaps("N")).toEqual(["N"]);
|
||||||
|
expect(keyCaps("?")).toEqual(["?"]);
|
||||||
|
expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommandSet", () => {
|
||||||
|
it("runs the command bound to a chord, and swallows the browser default", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
||||||
|
const e = key("n");
|
||||||
|
expect(set.handle(e)).toBe(true);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
expect(e.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unbound key alone", () => {
|
||||||
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||||
|
const e = key("q");
|
||||||
|
expect(set.handle(e)).toBe(false);
|
||||||
|
expect(e.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a modified key as a different binding", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "spawn.open": run }));
|
||||||
|
expect(set.handle(key("n", { ctrlKey: true }))).toBe(false);
|
||||||
|
expect(set.handle(key("n", { metaKey: true }))).toBe(false);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs every alias of a command", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "list.next": run }));
|
||||||
|
set.handle(key("j"));
|
||||||
|
set.handle(key("ArrowDown"));
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
||||||
|
const plain = vi.fn();
|
||||||
|
const chorded = vi.fn();
|
||||||
|
const set = new CommandSet(
|
||||||
|
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
||||||
|
);
|
||||||
|
const input = el("<input>");
|
||||||
|
expect(pressOn(set, input, "n")).toBe(false);
|
||||||
|
expect(plain).not.toHaveBeenCalled();
|
||||||
|
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
||||||
|
expect(chorded).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pane commands live inside the terminal's textarea", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
||||||
|
preventDefault: false,
|
||||||
|
});
|
||||||
|
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
const e = key("Escape");
|
||||||
|
Object.defineProperty(e, "target", { value: textarea });
|
||||||
|
expect(set.handle(e)).toBe(true);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
// xterm is told to swallow by the return value, not by the browser default.
|
||||||
|
expect(e.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the leader", () => {
|
||||||
|
it("defaults to the TUI's ctrl-a", () => {
|
||||||
|
expect(DEFAULT_LEADER).toBe("ctrl-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the leader as a partial chord instead of selecting the page", () => {
|
||||||
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||||
|
leader: DEFAULT_LEADER,
|
||||||
|
});
|
||||||
|
const e = key("a", { ctrlKey: true });
|
||||||
|
expect(set.handle(e)).toBe(true);
|
||||||
|
expect(e.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows the key after the leader rather than running its plain binding", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
||||||
|
leader: DEFAULT_LEADER,
|
||||||
|
});
|
||||||
|
set.handle(key("a", { ctrlKey: true }));
|
||||||
|
expect(set.handle(key("n"))).toBe(true);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
// The next key is an ordinary one again.
|
||||||
|
set.handle(key("n"));
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets a held leader once the chord timeout passes", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
let now = 0;
|
||||||
|
const set = new CommandSet(defineCommands({ "spawn.open": run }), {
|
||||||
|
leader: DEFAULT_LEADER,
|
||||||
|
chordTimeoutMs: 1000,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
set.handle(key("a", { ctrlKey: true }));
|
||||||
|
now = 1500;
|
||||||
|
set.handle(key("n"));
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves select-all alone in a real form field", () => {
|
||||||
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), {
|
||||||
|
leader: DEFAULT_LEADER,
|
||||||
|
});
|
||||||
|
const input = el("<input>");
|
||||||
|
const e = key("a", { ctrlKey: true });
|
||||||
|
Object.defineProperty(e, "target", { value: input });
|
||||||
|
expect(set.handle(e)).toBe(false);
|
||||||
|
expect(e.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("publishing", () => {
|
||||||
|
it("lists a constructed set's commands, replacing an id rather than doubling it", () => {
|
||||||
|
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
||||||
|
new CommandSet(defineCommands({ "list.kill": () => {} }));
|
||||||
|
const ids = publishedCommands().map((c) => c.id);
|
||||||
|
expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps published commands in registry order", () => {
|
||||||
|
const order = COMMAND_SPECS.map((s) => s.id);
|
||||||
|
const ids = publishedCommands().map((c) => c.id);
|
||||||
|
expect(ids).toEqual(order.filter((id) => ids.includes(id)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isTypingTarget", () => {
|
||||||
|
it("is true for form fields and editable nodes, false otherwise", () => {
|
||||||
|
expect(isTypingTarget(el("<input>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
||||||
|
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
||||||
|
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
||||||
|
expect(isTypingTarget(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
// The web client's command registry (#148). One entry per command — a stable
|
||||||
|
// id, a plain-language label, the key chord it answers to, and a `run()` — so
|
||||||
|
// there is exactly one list of what the client can do. The `?` overlay renders
|
||||||
|
// from it (`help.ts`), and every keybinding is dispatched through it; a command
|
||||||
|
// added here is bound and documented in the same edit.
|
||||||
|
//
|
||||||
|
// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical`
|
||||||
|
// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those
|
||||||
|
// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets
|
||||||
|
// docs/COMMAND-BAR.md describe one key model for both clients.
|
||||||
|
|
||||||
|
/** Where a command's keys are listened for. `global` is the window, `list` the
|
||||||
|
* sidebar's session tree, `pane` the attached terminal, `overlay` the help
|
||||||
|
* dialog while it is up. */
|
||||||
|
export type CommandScope = "global" | "list" | "pane" | "overlay";
|
||||||
|
|
||||||
|
export interface CommandSpec {
|
||||||
|
/** Stable identifier. Never rendered; safe to bind config and tests to. */
|
||||||
|
id: string;
|
||||||
|
/** Plain-language label — what the `?` overlay and (later) the command bar
|
||||||
|
* show. */
|
||||||
|
label: string;
|
||||||
|
/** Help-overlay section. Groups render in the order they first appear here. */
|
||||||
|
group: string;
|
||||||
|
/** Canonical chords that run it. The first is the one shown to the operator;
|
||||||
|
* the rest are aliases (`j` and `down`). */
|
||||||
|
keys: string[];
|
||||||
|
scope: CommandScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Command extends CommandSpec {
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every command the web client has. The single list this issue exists to
|
||||||
|
* create: labels, chords and grouping live here and nowhere else. */
|
||||||
|
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||||
|
{
|
||||||
|
id: "spawn.open",
|
||||||
|
label: "Open the spawn form",
|
||||||
|
group: "Sessions",
|
||||||
|
keys: ["n"],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spawn.fastForward",
|
||||||
|
label: "Spawn a session with every default resolved",
|
||||||
|
group: "Sessions",
|
||||||
|
keys: ["N"],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "focus.toggle",
|
||||||
|
label: "Move the keyboard between the session list and the pane",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["ctrl-shift-h"],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list.next",
|
||||||
|
label: "Highlight the next session",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["j", "down"],
|
||||||
|
scope: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list.prev",
|
||||||
|
label: "Highlight the previous session",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["k", "up"],
|
||||||
|
scope: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list.activate",
|
||||||
|
label: "Open the highlighted session, or expand a folder",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["enter"],
|
||||||
|
scope: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list.toggleFolder",
|
||||||
|
label: "Collapse or expand the highlighted folder",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["c"],
|
||||||
|
scope: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list.rename",
|
||||||
|
label: "Rename the highlighted session",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["r"],
|
||||||
|
scope: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list.kill",
|
||||||
|
label: "Kill the highlighted session",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["x"],
|
||||||
|
scope: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list.focusPane",
|
||||||
|
label: "Send the keyboard to the pane",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["tab"],
|
||||||
|
scope: "list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane.focusList",
|
||||||
|
label: "Hand the keyboard back to the session list, from the pane",
|
||||||
|
group: "Session list",
|
||||||
|
keys: ["esc"],
|
||||||
|
scope: "pane",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help.toggle",
|
||||||
|
label: "Show or hide this help",
|
||||||
|
group: "Help",
|
||||||
|
keys: ["?"],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help.close",
|
||||||
|
label: "Close the help overlay or an open menu",
|
||||||
|
group: "Help",
|
||||||
|
keys: ["esc"],
|
||||||
|
scope: "overlay",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "debug.toggleHud",
|
||||||
|
label: "Toggle the debug HUD",
|
||||||
|
group: "Debug",
|
||||||
|
keys: ["ctrl-shift-d"],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s]));
|
||||||
|
|
||||||
|
/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a
|
||||||
|
* test failure rather than a key that silently does nothing. */
|
||||||
|
export function defineCommands(handlers: Record<string, () => void>): Command[] {
|
||||||
|
return Object.entries(handlers).map(([id, run]) => {
|
||||||
|
const spec = SPEC_BY_ID.get(id);
|
||||||
|
if (!spec) throw new Error(`unknown command id: ${id}`);
|
||||||
|
return { ...spec, run };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- what the help overlay renders ----
|
||||||
|
|
||||||
|
const published = new Map<string, Command>();
|
||||||
|
|
||||||
|
/** Record commands as bound. Re-registering an id replaces it, so a second
|
||||||
|
* sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */
|
||||||
|
export function publishCommands(cmds: Command[]): void {
|
||||||
|
for (const c of cmds) published.set(c.id, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every bound command, in `COMMAND_SPECS` order. */
|
||||||
|
export function publishedCommands(): Command[] {
|
||||||
|
return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- key canonicalisation ----
|
||||||
|
|
||||||
|
const NAMED: Record<string, string> = {
|
||||||
|
Escape: "esc",
|
||||||
|
Enter: "enter",
|
||||||
|
Tab: "tab",
|
||||||
|
Backspace: "backspace",
|
||||||
|
ArrowUp: "up",
|
||||||
|
ArrowDown: "down",
|
||||||
|
ArrowLeft: "left",
|
||||||
|
ArrowRight: "right",
|
||||||
|
Home: "home",
|
||||||
|
End: "end",
|
||||||
|
PageUp: "pageup",
|
||||||
|
PageDown: "pagedown",
|
||||||
|
Delete: "delete",
|
||||||
|
" ": "space",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The canonical string for one keydown. Mirrors the TUI's
|
||||||
|
* `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the
|
||||||
|
* key. A shifted character already spells itself (`N`, `?`), so `shift-` is
|
||||||
|
* only written when another modifier is present and the char was folded to
|
||||||
|
* lower case. */
|
||||||
|
export function canonicalizeKey(e: KeyboardEvent): string | null {
|
||||||
|
if (!e.key) return null;
|
||||||
|
// Modifier keys pressed alone are never a binding.
|
||||||
|
if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null;
|
||||||
|
const named = NAMED[e.key];
|
||||||
|
if (named === "tab" && e.shiftKey) return "backtab";
|
||||||
|
let out = "";
|
||||||
|
if (e.ctrlKey) out += "ctrl-";
|
||||||
|
if (e.altKey) out += "alt-";
|
||||||
|
if (e.metaKey) out += "meta-";
|
||||||
|
if (named) {
|
||||||
|
if (e.shiftKey) out += "shift-";
|
||||||
|
return out + named;
|
||||||
|
}
|
||||||
|
if (e.key.length === 1) {
|
||||||
|
if (out === "") return e.key;
|
||||||
|
if (e.shiftKey) out += "shift-";
|
||||||
|
return out + e.key.toLowerCase();
|
||||||
|
}
|
||||||
|
if (e.shiftKey) out += "shift-";
|
||||||
|
return out + e.key.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
||||||
|
|
||||||
|
/** True when a chord carries a modifier or more than one key. Plain single-key
|
||||||
|
* chords are the ones that stay deaf inside form fields. */
|
||||||
|
function isChorded(chord: string): boolean {
|
||||||
|
return chord.includes(" ") || MOD_PREFIX.test(chord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the event target swallows plain keystrokes — a form field, or the
|
||||||
|
* hidden textarea xterm types into. Every plain single-key binding is deaf in
|
||||||
|
* the same places. */
|
||||||
|
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
const el = target as HTMLElement | null;
|
||||||
|
if (!el || typeof el.tagName !== "string") return false;
|
||||||
|
return (
|
||||||
|
el.tagName === "INPUT" ||
|
||||||
|
el.tagName === "TEXTAREA" ||
|
||||||
|
el.tagName === "SELECT" ||
|
||||||
|
el.isContentEditable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
||||||
|
* also where the leader has to work — a pane is exactly where the operator
|
||||||
|
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
||||||
|
function isTerminalTextarea(target: EventTarget | null): boolean {
|
||||||
|
const el = target as HTMLElement | null;
|
||||||
|
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- display ----
|
||||||
|
|
||||||
|
const CAPS: Record<string, string> = {
|
||||||
|
ctrl: "Ctrl",
|
||||||
|
alt: "Alt",
|
||||||
|
meta: "Meta",
|
||||||
|
shift: "Shift",
|
||||||
|
esc: "Esc",
|
||||||
|
enter: "Enter",
|
||||||
|
tab: "Tab",
|
||||||
|
backtab: "Shift Tab",
|
||||||
|
backspace: "Backspace",
|
||||||
|
up: "Up",
|
||||||
|
down: "Down",
|
||||||
|
left: "Left",
|
||||||
|
right: "Right",
|
||||||
|
home: "Home",
|
||||||
|
end: "End",
|
||||||
|
pageup: "PgUp",
|
||||||
|
pagedown: "PgDn",
|
||||||
|
delete: "Del",
|
||||||
|
space: "Space",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A
|
||||||
|
* multi-key chord separates its keys with a `then` cap. */
|
||||||
|
export function keyCaps(chord: string): string[] {
|
||||||
|
const caps: string[] = [];
|
||||||
|
for (const [i, key] of chord.split(" ").entries()) {
|
||||||
|
if (i > 0) caps.push("then");
|
||||||
|
const parts = key.split("-");
|
||||||
|
// A bare `-` key splits into two empties; treat it as the key itself.
|
||||||
|
if (parts.every((p) => p === "")) {
|
||||||
|
caps.push("-");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const last = parts.pop() as string;
|
||||||
|
const mods = parts.filter((p) => p !== "");
|
||||||
|
for (const m of mods) caps.push(CAPS[m] ?? m);
|
||||||
|
caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last));
|
||||||
|
}
|
||||||
|
return caps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dispatch ----
|
||||||
|
|
||||||
|
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
||||||
|
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
||||||
|
export const DEFAULT_LEADER = "ctrl-a";
|
||||||
|
|
||||||
|
/** How long a partial chord waits for its next key — the TUI's
|
||||||
|
* `chord_timeout_ms` default. */
|
||||||
|
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
||||||
|
|
||||||
|
export interface CommandSetOptions {
|
||||||
|
/** Canonical leader key. A set given one holds every leader press as a
|
||||||
|
* pending chord, whether or not anything is bound behind it yet. */
|
||||||
|
leader?: string | null;
|
||||||
|
chordTimeoutMs?: number;
|
||||||
|
/** Call `preventDefault()` on a handled key. Off for the terminal's own key
|
||||||
|
* handler, which swallows by returning `false` to xterm instead. */
|
||||||
|
preventDefault?: boolean;
|
||||||
|
/** Clock, for tests. */
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group of commands listening on one surface. Resolves a keydown to a
|
||||||
|
* command the way the TUI's `ChordState` does: hold while the sequence is a
|
||||||
|
* strict prefix of some binding, otherwise resolve and dispatch. */
|
||||||
|
export class CommandSet {
|
||||||
|
private readonly byChord = new Map<string, Command>();
|
||||||
|
private readonly leader: string | null;
|
||||||
|
private readonly timeout: number;
|
||||||
|
private readonly preventDefault: boolean;
|
||||||
|
private readonly now: () => number;
|
||||||
|
private held: string[] = [];
|
||||||
|
private lastPress = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly commands: Command[],
|
||||||
|
opts: CommandSetOptions = {},
|
||||||
|
) {
|
||||||
|
for (const c of commands) for (const k of c.keys) this.byChord.set(k, c);
|
||||||
|
this.leader = opts.leader ?? null;
|
||||||
|
this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS;
|
||||||
|
this.preventDefault = opts.preventDefault ?? true;
|
||||||
|
this.now = opts.now ?? (() => Date.now());
|
||||||
|
publishCommands(commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any binding starts with `candidate` and continues. */
|
||||||
|
private isPrefix(candidate: string): boolean {
|
||||||
|
const marker = `${candidate} `;
|
||||||
|
for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed one keydown. Returns true when the key was consumed — dispatched, or
|
||||||
|
* held as a partial chord. */
|
||||||
|
handle(e: KeyboardEvent): boolean {
|
||||||
|
const key = canonicalizeKey(e);
|
||||||
|
if (key === null) return false;
|
||||||
|
if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = [];
|
||||||
|
const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key;
|
||||||
|
this.lastPress = this.now();
|
||||||
|
|
||||||
|
// The leader is a prefix by construction, so it stops being select-all
|
||||||
|
// before anything is bound behind it (#149 binds the first chord).
|
||||||
|
if (this.leader !== null && this.held.length === 0 && key === this.leader) {
|
||||||
|
if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false;
|
||||||
|
this.held = [key];
|
||||||
|
if (this.preventDefault) e.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPrefix(candidate)) {
|
||||||
|
this.held.push(key);
|
||||||
|
if (this.preventDefault) e.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasChord = this.held.length > 0;
|
||||||
|
this.held = [];
|
||||||
|
const cmd = this.byChord.get(candidate);
|
||||||
|
if (!cmd) {
|
||||||
|
// A chord that resolved to nothing is still consumed: the leader took
|
||||||
|
// the key, so it must not also fall through to a plain binding.
|
||||||
|
return wasChord;
|
||||||
|
}
|
||||||
|
// Plain single-key bindings are deaf where typing happens; chorded ones are
|
||||||
|
// not, and neither are the pane's and the overlay's, which are only fed
|
||||||
|
// from the surface that owns them.
|
||||||
|
if (
|
||||||
|
!isChorded(candidate) &&
|
||||||
|
cmd.scope !== "pane" &&
|
||||||
|
cmd.scope !== "overlay" &&
|
||||||
|
isTypingTarget(e.target)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.preventDefault) e.preventDefault();
|
||||||
|
cmd.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-6
@@ -2,6 +2,8 @@
|
|||||||
// survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by
|
// survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by
|
||||||
// `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change.
|
// `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change.
|
||||||
|
|
||||||
|
import { CommandSet, defineCommands } from "./commands.js";
|
||||||
|
|
||||||
const STORAGE_KEY = "maestro:hud";
|
const STORAGE_KEY = "maestro:hud";
|
||||||
export const HUD_TOGGLE_EVENT = "maestro-hud-toggle";
|
export const HUD_TOGGLE_EVENT = "maestro-hud-toggle";
|
||||||
|
|
||||||
@@ -31,12 +33,9 @@ export function toggleHud(): void {
|
|||||||
setHudEnabled(!isHudEnabled());
|
setHudEnabled(!isHudEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hudKeys = new CommandSet(defineCommands({ "debug.toggleHud": () => toggleHud() }));
|
||||||
|
|
||||||
/** Global keybind: Ctrl+Shift+D toggles the debug HUD. */
|
/** Global keybind: Ctrl+Shift+D toggles the debug HUD. */
|
||||||
export function installHudKeybind(): void {
|
export function installHudKeybind(): void {
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => hudKeys.handle(e));
|
||||||
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleHud();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-30
@@ -1,14 +1,8 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import { keyCaps, publishedCommands } from "./commands.js";
|
||||||
SHORTCUTS,
|
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
|
||||||
closeHelp,
|
|
||||||
installHelpKeybind,
|
|
||||||
isHelpOpen,
|
|
||||||
isTypingTarget,
|
|
||||||
openHelp,
|
|
||||||
} from "./help.js";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
installHelpKeybind();
|
installHelpKeybind();
|
||||||
@@ -30,14 +24,24 @@ function rows(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("help overlay", () => {
|
describe("help overlay", () => {
|
||||||
it("renders every registered shortcut, grouped", () => {
|
it("renders exactly the commands the registry has bound, grouped", () => {
|
||||||
openHelp();
|
openHelp();
|
||||||
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
|
const groups = groupedCommands();
|
||||||
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
|
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
||||||
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
|
groups.map((g) => g.title),
|
||||||
expect(document.querySelectorAll(".help-desc")).toHaveLength(
|
|
||||||
SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
|
|
||||||
);
|
);
|
||||||
|
expect(rows()).toEqual(
|
||||||
|
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
|
||||||
|
);
|
||||||
|
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
|
||||||
|
groups.flatMap((g) => g.items.map((c) => c.label)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names each row by its command id, so a row can't drift from its command", () => {
|
||||||
|
openHelp();
|
||||||
|
expect([...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId))
|
||||||
|
.toEqual(publishedCommands().map((c) => c.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens on ? and closes on a second ?", () => {
|
it("opens on ? and closes on a second ?", () => {
|
||||||
@@ -94,19 +98,3 @@ describe("help overlay", () => {
|
|||||||
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isTypingTarget", () => {
|
|
||||||
it("is true for form fields and editable nodes, false otherwise", () => {
|
|
||||||
const el = (html: string) => {
|
|
||||||
const d = document.createElement("div");
|
|
||||||
d.innerHTML = html;
|
|
||||||
return d.firstElementChild as HTMLElement;
|
|
||||||
};
|
|
||||||
expect(isTypingTarget(el("<input>"))).toBe(true);
|
|
||||||
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
|
||||||
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
|
||||||
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
|
||||||
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
|
||||||
expect(isTypingTarget(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+37
-79
@@ -1,69 +1,15 @@
|
|||||||
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
||||||
* overlay. One registry drives both the rendering and what we claim is bound,
|
* overlay. It renders the command registry (`commands.ts`) — the same list
|
||||||
* so a binding added to `main.ts` without an entry here reads as missing. */
|
* that dispatches the keys — so a binding can never be documented as one
|
||||||
|
* thing and do another. */
|
||||||
|
|
||||||
export interface Shortcut {
|
import {
|
||||||
/** Keycaps, rendered left to right as separate <kbd>. */
|
CommandSet,
|
||||||
keys: string[];
|
defineCommands,
|
||||||
description: string;
|
keyCaps,
|
||||||
}
|
publishedCommands,
|
||||||
|
type Command,
|
||||||
export interface ShortcutGroup {
|
} from "./commands.js";
|
||||||
title: string;
|
|
||||||
items: Shortcut[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SHORTCUTS: ShortcutGroup[] = [
|
|
||||||
{
|
|
||||||
title: "Sessions",
|
|
||||||
items: [
|
|
||||||
{ keys: ["n"], description: "Open the spawn form" },
|
|
||||||
{ keys: ["N"], description: "Spawn a session with every default resolved" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Session list",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
keys: ["Ctrl", "Shift", "H"],
|
|
||||||
description: "Move the keyboard between the session list and the pane",
|
|
||||||
},
|
|
||||||
{ keys: ["j"], description: "Highlight the next session" },
|
|
||||||
{ keys: ["k"], description: "Highlight the previous session" },
|
|
||||||
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
|
||||||
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
|
|
||||||
{ keys: ["r"], description: "Rename the highlighted session" },
|
|
||||||
{ keys: ["x"], description: "Kill the highlighted session" },
|
|
||||||
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
|
||||||
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Help",
|
|
||||||
items: [
|
|
||||||
{ keys: ["?"], description: "Show or hide this help" },
|
|
||||||
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Debug",
|
|
||||||
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** True when the event target swallows plain keystrokes — a form field, or the
|
|
||||||
* hidden textarea xterm types into. Shared with `main.ts` so every global
|
|
||||||
* single-key binding is deaf in the same places. */
|
|
||||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
||||||
const el = target as HTMLElement | null;
|
|
||||||
if (!el || typeof el.tagName !== "string") return false;
|
|
||||||
return (
|
|
||||||
el.tagName === "INPUT" ||
|
|
||||||
el.tagName === "TEXTAREA" ||
|
|
||||||
el.tagName === "SELECT" ||
|
|
||||||
el.isContentEditable
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
@@ -108,7 +54,7 @@ export function openHelp(): HTMLElement {
|
|||||||
head.append(title, close);
|
head.append(title, close);
|
||||||
panel.appendChild(head);
|
panel.appendChild(head);
|
||||||
|
|
||||||
for (const group of SHORTCUTS) {
|
for (const group of groupedCommands()) {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "help-group";
|
section.className = "help-group";
|
||||||
const heading = document.createElement("h3");
|
const heading = document.createElement("h3");
|
||||||
@@ -116,19 +62,20 @@ export function openHelp(): HTMLElement {
|
|||||||
heading.textContent = group.title;
|
heading.textContent = group.title;
|
||||||
const list = document.createElement("dl");
|
const list = document.createElement("dl");
|
||||||
list.className = "help-list";
|
list.className = "help-list";
|
||||||
for (const item of group.items) {
|
for (const cmd of group.items) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "help-row";
|
row.className = "help-row";
|
||||||
|
row.dataset.commandId = cmd.id;
|
||||||
const keys = document.createElement("dt");
|
const keys = document.createElement("dt");
|
||||||
keys.className = "help-keys";
|
keys.className = "help-keys";
|
||||||
for (const key of item.keys) {
|
for (const key of keyCaps(cmd.keys[0])) {
|
||||||
const cap = document.createElement("kbd");
|
const cap = document.createElement("kbd");
|
||||||
cap.textContent = key;
|
cap.textContent = key;
|
||||||
keys.appendChild(cap);
|
keys.appendChild(cap);
|
||||||
}
|
}
|
||||||
const desc = document.createElement("dd");
|
const desc = document.createElement("dd");
|
||||||
desc.className = "help-desc";
|
desc.className = "help-desc";
|
||||||
desc.textContent = item.description;
|
desc.textContent = cmd.label;
|
||||||
row.append(keys, desc);
|
row.append(keys, desc);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
@@ -153,6 +100,26 @@ export function openHelp(): HTMLElement {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The registry's commands, in registry order, bucketed into the help
|
||||||
|
* overlay's sections. Groups appear in the order their first command does. */
|
||||||
|
export function groupedCommands(): { title: string; items: Command[] }[] {
|
||||||
|
const groups: { title: string; items: Command[] }[] = [];
|
||||||
|
for (const cmd of publishedCommands()) {
|
||||||
|
let group = groups.find((g) => g.title === cmd.group);
|
||||||
|
if (!group) {
|
||||||
|
group = { title: cmd.group, items: [] };
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
group.items.push(cmd);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only
|
||||||
|
// fed keys while the dialog is up.
|
||||||
|
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() }));
|
||||||
|
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() }));
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
||||||
@@ -161,16 +128,7 @@ export function installHelpKeybind(): void {
|
|||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (open && overlayKeys.handle(e)) return;
|
||||||
e.preventDefault();
|
helpKeys.handle(e);
|
||||||
closeHelp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Shift is how `?` is typed on most layouts; the others would be a
|
|
||||||
// different binding entirely.
|
|
||||||
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
||||||
if (isTypingTarget(e.target)) return;
|
|
||||||
e.preventDefault();
|
|
||||||
toggleHelp();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-41
@@ -6,7 +6,8 @@ import { renderSidebar } from "./catalog.js";
|
|||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||||
|
import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js";
|
||||||
import { DaemonLink } from "./daemonLink.js";
|
import { DaemonLink } from "./daemonLink.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import { installRestartOverlay } from "./restartOverlay.js";
|
import { installRestartOverlay } from "./restartOverlay.js";
|
||||||
@@ -29,6 +30,7 @@ import { mountHud, type Hud } from "./hud.js";
|
|||||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
import { loadLayout, saveLayout } from "./persistence.js";
|
import { loadLayout, saveLayout } from "./persistence.js";
|
||||||
|
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||||
import {
|
import {
|
||||||
FOCUS_PANE_EVENT,
|
FOCUS_PANE_EVENT,
|
||||||
FOCUS_SIDEBAR_EVENT,
|
FOCUS_SIDEBAR_EVENT,
|
||||||
@@ -105,20 +107,14 @@ shellEl.dataset.route = "catalog";
|
|||||||
// point `mv` is assigned.
|
// point `mv` is assigned.
|
||||||
let mv: MultiView;
|
let mv: MultiView;
|
||||||
|
|
||||||
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
|
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
|
||||||
// mouse sweep waits long enough that rows crossed on the way somewhere else
|
// — j held down, or the pointer sweeping the whole list — is coalesced so it
|
||||||
// never flash up. The same delay applies to clearing, so the gap between two
|
// costs a couple of pane mounts instead of one per row walked.
|
||||||
// rows doesn't blink the previous view back.
|
const schedulePreview = createPreviewSchedule((sessionId) => {
|
||||||
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
|
ensureMultiView();
|
||||||
let previewTimer: number | undefined;
|
mv?.previewSession(sessionId);
|
||||||
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
|
updateShellRoute();
|
||||||
clearTimeout(previewTimer);
|
});
|
||||||
previewTimer = window.setTimeout(() => {
|
|
||||||
ensureMultiView();
|
|
||||||
mv?.previewSession(sessionId);
|
|
||||||
updateShellRoute();
|
|
||||||
}, PREVIEW_DELAY[source]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidebar = renderSidebar(store, {
|
const sidebar = renderSidebar(store, {
|
||||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||||
@@ -366,37 +362,48 @@ async function requestFastForward(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn.
|
// The global half of the command registry (#148). Two sets, because they
|
||||||
// Ignored while typing anywhere (form fields, the terminal's hidden
|
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
||||||
// textarea, search) so an attached session's keys are never hijacked.
|
// the plain keys go deaf behind it while the focus chord does not — an
|
||||||
document.addEventListener("keydown", (e) => {
|
// attached terminal must never be able to trap the focus keys.
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
//
|
||||||
if (isTypingTarget(e.target)) return;
|
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
||||||
// The help overlay owns the keyboard while it is up.
|
// keys, so `isTypingTarget` keeps them out of form fields and the terminal's
|
||||||
if (isHelpOpen()) return;
|
// hidden textarea.
|
||||||
if (e.key === "n") {
|
const spawnKeys = new CommandSet(
|
||||||
e.preventDefault();
|
defineCommands({
|
||||||
requestOpenSpawn();
|
"spawn.open": () => requestOpenSpawn(),
|
||||||
} else if (e.key === "N") {
|
"spawn.fastForward": () => void requestFastForward(),
|
||||||
e.preventDefault();
|
}),
|
||||||
void requestFastForward();
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
||||||
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
||||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||||
// the focus back out of it.
|
// the focus back out of it.
|
||||||
|
//
|
||||||
|
// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the
|
||||||
|
// key as a partial chord and takes select-all off it, so the escape hatch
|
||||||
|
// docs/COMMAND-BAR.md describes has somewhere to land (#149).
|
||||||
|
const focusKeys = new CommandSet(
|
||||||
|
defineCommands({
|
||||||
|
"focus.toggle": () => {
|
||||||
|
if (sidebar.hasListFocus()) {
|
||||||
|
pool.focusTerminal();
|
||||||
|
} else {
|
||||||
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
|
sidebar.focusList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ leader: DEFAULT_LEADER },
|
||||||
|
);
|
||||||
|
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
if (focusKeys.handle(e)) return;
|
||||||
if (e.key !== "H" && e.key !== "h") return;
|
// The help overlay owns the keyboard while it is up.
|
||||||
e.preventDefault();
|
if (isHelpOpen()) return;
|
||||||
if (sidebar.hasListFocus()) {
|
spawnKeys.handle(e);
|
||||||
pool.focusTerminal();
|
|
||||||
} else {
|
|
||||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
|
||||||
sidebar.focusList();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||||
@@ -418,7 +425,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
|||||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
* 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. */
|
* changing, leaving a click back onto that session's row silently ignored. */
|
||||||
function focusSession(sessionId: string): void {
|
function focusSession(sessionId: string): void {
|
||||||
clearTimeout(previewTimer);
|
schedulePreview.cancel();
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openSession(sessionId);
|
mv.openSession(sessionId);
|
||||||
updateShellRoute();
|
updateShellRoute();
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
|
||||||
|
|
||||||
|
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
|
||||||
|
function harness() {
|
||||||
|
let now = 1_000;
|
||||||
|
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||||
|
let nextHandle = 1;
|
||||||
|
const emitted: (string | null)[] = [];
|
||||||
|
|
||||||
|
const schedule = createPreviewSchedule((id) => emitted.push(id), {
|
||||||
|
now: () => now,
|
||||||
|
setTimer: (fn, ms) => {
|
||||||
|
const h = nextHandle++;
|
||||||
|
timers.set(h, { at: now + ms, fn });
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer: (h) => {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Advance the clock, firing any timer that comes due. */
|
||||||
|
const advance = (ms: number) => {
|
||||||
|
now += ms;
|
||||||
|
for (const [h, t] of [...timers]) {
|
||||||
|
if (t.at <= now) {
|
||||||
|
timers.delete(h);
|
||||||
|
t.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { schedule, emitted, advance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("preview schedule", () => {
|
||||||
|
it("shows the first preview immediately", () => {
|
||||||
|
const { schedule, emitted } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces a burst into one trailing preview", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
// A pointer sweep across the list, or j held down: every row in between
|
||||||
|
// is walked well inside one gap.
|
||||||
|
schedule("s1");
|
||||||
|
schedule("s2");
|
||||||
|
schedule("s3");
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
expect(emitted).toEqual(["s1", "s3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the next preview immediately once the gap has passed", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
schedule("s2");
|
||||||
|
expect(emitted).toEqual(["s1", "s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a superseded trailing preview rather than replaying it", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
schedule("s2");
|
||||||
|
schedule("s1"); // back where we started inside the same gap
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
// s1 is already on screen, so the trailing edge has nothing to do.
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears through the same gap, so leaving and re-entering does not blink", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
schedule(null); // pointer crosses the gap between two rows
|
||||||
|
schedule("s2");
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
expect(emitted).toEqual(["s1", "s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel drops a pending trailing preview", () => {
|
||||||
|
const { schedule, emitted, advance } = harness();
|
||||||
|
schedule("s1");
|
||||||
|
schedule("s2");
|
||||||
|
schedule.cancel();
|
||||||
|
advance(PREVIEW_MIN_GAP_MS);
|
||||||
|
expect(emitted).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
|
||||||
|
*
|
||||||
|
* The highlight itself is never scheduled — that is a class on a row and lands
|
||||||
|
* on the same frame as the input. This governs only the pane swap behind it,
|
||||||
|
* which mounts a terminal and, for a session the connection pool isn't already
|
||||||
|
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
|
||||||
|
*
|
||||||
|
* A plain debounce would put that cost in front of every preview, including the
|
||||||
|
* one preview the operator actually meant. Instead the first move shows at once
|
||||||
|
* and the rest of a burst is coalesced: walking a list with `j` held down, or
|
||||||
|
* sweeping the pointer from the top of the sidebar to the bottom, costs one
|
||||||
|
* preview at the start and one at the row you stopped on, not one per row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
|
||||||
|
* couple of terminals rather than one per row; short enough that a deliberate
|
||||||
|
* second move never waits on it. */
|
||||||
|
export const PREVIEW_MIN_GAP_MS = 120;
|
||||||
|
|
||||||
|
export interface PreviewScheduleDeps {
|
||||||
|
now?: () => number;
|
||||||
|
setTimer?: (fn: () => void, ms: number) => number;
|
||||||
|
clearTimer?: (handle: number) => void;
|
||||||
|
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
|
||||||
|
minGapMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewSchedule {
|
||||||
|
(sessionId: string | null): void;
|
||||||
|
/** Drop a pending trailing preview without showing it. */
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap `show` so it fires on the leading edge and at most once per gap.
|
||||||
|
*
|
||||||
|
* `show` is called with the session to preview, or `null` to put back whatever
|
||||||
|
* the operator actually opened.
|
||||||
|
*/
|
||||||
|
export function createPreviewSchedule(
|
||||||
|
show: (sessionId: string | null) => void,
|
||||||
|
deps: PreviewScheduleDeps = {},
|
||||||
|
): PreviewSchedule {
|
||||||
|
const now = deps.now ?? (() => Date.now());
|
||||||
|
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
|
||||||
|
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
|
||||||
|
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
|
||||||
|
|
||||||
|
let lastShownAt = Number.NEGATIVE_INFINITY;
|
||||||
|
let shown: string | null | undefined;
|
||||||
|
let pendingTimer: number | undefined;
|
||||||
|
let pendingId: string | null = null;
|
||||||
|
|
||||||
|
const emit = (id: string | null) => {
|
||||||
|
// Nothing to do when the burst ended on the view already up: re-showing it
|
||||||
|
// would tear down and re-mount the pane for no visible change.
|
||||||
|
if (shown === id) return;
|
||||||
|
shown = id;
|
||||||
|
lastShownAt = now();
|
||||||
|
show(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPending = () => {
|
||||||
|
if (pendingTimer === undefined) return;
|
||||||
|
clearTimer(pendingTimer);
|
||||||
|
pendingTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = ((sessionId: string | null) => {
|
||||||
|
pendingId = sessionId;
|
||||||
|
const waited = now() - lastShownAt;
|
||||||
|
if (waited >= minGap) {
|
||||||
|
cancelPending();
|
||||||
|
emit(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
|
||||||
|
pendingTimer = setTimer(() => {
|
||||||
|
pendingTimer = undefined;
|
||||||
|
emit(pendingId);
|
||||||
|
}, minGap - waited);
|
||||||
|
}) as PreviewSchedule;
|
||||||
|
|
||||||
|
schedule.cancel = cancelPending;
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
@@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
|
|
||||||
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
||||||
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
|
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
|
||||||
const seen: [string | null, string][] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
|
|
||||||
press(tree(el), "j");
|
press(tree(el), "j");
|
||||||
press(tree(el), "j");
|
press(tree(el), "j");
|
||||||
expect(seen).toEqual([
|
expect(seen).toEqual([A, B]);
|
||||||
[A, "key"],
|
|
||||||
[B, "key"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
tree(el).dispatchEvent(new FocusEvent("blur"));
|
tree(el).dispatchEvent(new FocusEvent("blur"));
|
||||||
expect(seen.at(-1)).toEqual([null, "key"]);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||||
const seen: [string | null, string][] = [];
|
const seen: (string | null)[] = [];
|
||||||
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
expect(seen).toEqual([[A, "hover"]]);
|
expect(seen).toEqual([A]);
|
||||||
// Moving within the same card is not a new preview.
|
// Moving within the same card is not a new preview.
|
||||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
expect(seen).toHaveLength(1);
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||||
expect(seen.at(-1)).toEqual([null, "hover"]);
|
expect(seen.at(-1)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores touch, where a tap already opens the session", () => {
|
it("ignores touch, where a tap already opens the session", () => {
|
||||||
|
|||||||
+8
-7
@@ -1064,7 +1064,6 @@ textarea {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-12);
|
font-size: var(--fs-12);
|
||||||
transition: color var(--dur-fast) var(--ease-out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-folder-header:hover {
|
.tree-folder-header:hover {
|
||||||
@@ -1076,9 +1075,9 @@ textarea {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
font-size: var(--fs-11);
|
font-size: var(--fs-11);
|
||||||
transition:
|
/* The rotation is the collapse animation and stays; the hover colour is
|
||||||
transform var(--dur) var(--ease-out),
|
immediate, like the row's own hover. */
|
||||||
color var(--dur-fast) var(--ease-out);
|
transition: transform var(--dur) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-folder-header[aria-expanded="false"] .chevron {
|
.tree-folder-header[aria-expanded="false"] .chevron {
|
||||||
@@ -1115,9 +1114,11 @@ textarea {
|
|||||||
border-radius: var(--r-1);
|
border-radius: var(--r-1);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
position: relative;
|
position: relative;
|
||||||
transition:
|
/* No transition on `background`: it is what hover paints, and easing it puts
|
||||||
background var(--dur-fast) var(--ease-out),
|
a visible delay between the pointer landing on a row and the row admitting
|
||||||
box-shadow var(--dur-fast) var(--ease-out);
|
it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
|
||||||
|
follows a keyboard walk, not the pointer. */
|
||||||
|
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
||||||
|
|||||||
Reference in New Issue
Block a user