Compare commits

..
1 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 f801b12e87 fix(maestro): suffix branch name on new-branch worktree collision
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:11 +01:00
20 changed files with 97 additions and 337 deletions
Generated
-1
View File
@@ -1051,7 +1051,6 @@ dependencies = [
"maestro-cli", "maestro-cli",
"maestro-daemon", "maestro-daemon",
"maestro-tui", "maestro-tui",
"tempfile",
"tokio", "tokio",
"tracing-subscriber", "tracing-subscriber",
] ]
+5 -17
View File
@@ -405,17 +405,13 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
); );
} }
/// `git worktree add -b` alone fails outright when the branch already exists,
/// even with the target directory free — a random handle commonly collides
/// with one an earlier, already-removed worktree left behind. `add_new`
/// sidesteps it on the branch name itself instead of erroring (#77).
#[test] #[test]
fn add_new_suffixes_branch_name_on_branch_collision() { fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap(); let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf(); let repo = repo_dir.path().to_path_buf();
init_repo(&repo); init_repo(&repo);
// pre-create the branch, unused by any worktree. // pre-create the branch
let status = Command::new("git") let status = Command::new("git")
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
@@ -427,17 +423,9 @@ fn add_new_suffixes_branch_name_on_branch_collision() {
let wt_root = TempDir::new().unwrap(); let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
.add_new(&repo, "feat/dup") let msg = format!("{err}");
.expect("sidesteps the branch collision"); assert!(msg.contains("git exited"), "{msg}");
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.exists());
// The pre-existing branch is untouched; the new worktree checked out the
// suffixed one instead of erroring or adopting `feat/dup`.
let branches = worktree_branches(&repo);
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
} }
/// The project picker lists linked worktrees as pickable rows, so `base_repo` /// The project picker lists linked worktrees as pickable rows, so `base_repo`
+24 -31
View File
@@ -323,11 +323,10 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill"); client.kill_session(s.id).await.expect("kill");
} }
/// #66/#77: a second spawn onto the same branch used to be rejected outright; /// #66: a second spawn onto a branch that already has a worktree is rejected
/// since the branch-collision suffixing landed, it succeeds instead — the /// with a structured worktree error; the first worktree stays intact.
/// second worktree lands on `<branch>-2` and the first stays untouched.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_gets_suffixed() { async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap(); let base = TempDir::new().unwrap();
@@ -337,33 +336,28 @@ async fn second_spawn_on_same_branch_gets_suffixed() {
.spawn_session(&worktree_req(base.path(), "feature/dup", true)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await .await
.expect("first spawn"); .expect("first spawn");
let second = client let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true)) .spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await .await;
.expect("second spawn suffixes instead of rejecting"); match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
let suffixed = base.path().join("worktrees").join("feature").join("dup-2"); // Exactly one registration, one directory, still usable.
assert_eq!( // Count branch records only: the worktree path now contains the branch
second.cwd, suffixed, // name too (`worktrees/feature/dup`), so a substring match over every line
"second worktree must land on the suffixed path" // would count the same tree twice.
); let holders = git_worktree_list(base.path())
assert!(suffixed.is_dir()); .iter()
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
// Both registrations exist: the original branch, untouched, and the .count();
// suffixed one the second spawn created. assert_eq!(holders, 1, "duplicate spawn must not register another tree");
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
"original branch must stay registered: {registrations:?}"
);
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
"suffixed branch must be registered too: {registrations:?}"
);
assert!( assert!(
base.path() base.path()
.join("worktrees") .join("worktrees")
@@ -374,8 +368,7 @@ async fn second_spawn_on_same_branch_gets_suffixed() {
); );
let _origin = throwaway_origin(base.path()); let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill first"); client.kill_session(first.id).await.expect("kill");
client.kill_session(second.id).await.expect("kill second");
} }
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement /// #66/#56: when the client picks a *linked worktree* as base_repo, placement
-3
View File
@@ -29,6 +29,3 @@ tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false } maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false } maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
maestro-tui = { path = "../maestro-tui", optional = true } maestro-tui = { path = "../maestro-tui", optional = true }
[dev-dependencies]
tempfile.workspace = true
-102
View File
@@ -1,102 +0,0 @@
//! Process-level routing for the umbrella `maestro` binary (#10): each
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
//! not just parse successfully.
//!
//! A malformed `--config` file makes every crate's dispatch fail identically
//! at config load, before any subcommand touches tmux, the network, or a
//! terminal. That lets the assertions tell "clap parsed it but the handler
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
//! exit 1).
//!
//! The absence tests below only compile when the matching feature is off, so
//! they run under CI's per-feature-subset matrix (`--no-default-features
//! --features cli|daemon|tui`) rather than the default all-features build.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
}
fn bad_config() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
f
}
fn run(args: &[&str]) -> Output {
Command::new(bin())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
}
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
/// config crate's error surfacing through — never clap's own usage-error
/// page, which would mean the subcommand never reached its handler.
fn assert_dispatched(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
}
/// Never reached a handler: clap rejected the subcommand outright because the
/// feature that defines it is off. Only used by the `not(feature = "...")`
/// tests below, which themselves only exist in a single-feature build.
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
fn assert_unrecognized(out: &Output, name: &str) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
assert!(
stderr.contains("unrecognized subcommand"),
"stderr:\n{stderr}"
);
assert!(stderr.contains(name), "stderr:\n{stderr}");
}
#[cfg(feature = "cli")]
#[test]
fn list_dispatches_into_maestro_cli() {
let cfg = bad_config();
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "cli"))]
#[test]
fn list_is_unrecognized_without_cli_feature() {
assert_unrecognized(&run(&["list"]), "list");
}
#[cfg(feature = "daemon")]
#[test]
fn daemon_dispatches_into_maestro_daemon() {
let cfg = bad_config();
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "daemon"))]
#[test]
fn daemon_is_unrecognized_without_daemon_feature() {
assert_unrecognized(&run(&["daemon"]), "daemon");
}
#[cfg(feature = "tui")]
#[test]
fn tui_dispatches_into_maestro_tui() {
let cfg = bad_config();
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "tui"))]
#[test]
fn tui_is_unrecognized_without_tui_feature() {
assert_unrecognized(&run(&["tui"]), "tui");
}
+3 -4
View File
@@ -307,10 +307,9 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list. than jumping to the end of the list.
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h` The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
by default — and the same chord back): an attached terminal owns every plain same chord back): an attached terminal owns every plain keystroke. The TUI uses
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI `ctrl-w h`.
uses `ctrl-w h`.
Everything else is the same key on both clients: Everything else is the same key on both clients:
+28 -42
View File
@@ -4,6 +4,8 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js"; import { attachWsUrl } from "./routes.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";
@@ -12,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();
@@ -43,10 +54,6 @@ export interface AttachConnection {
* handling — the leader's literal Ctrl-K escape hatch * handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */ * (docs/COMMAND-BAR.md). */
sendInput(data: string): void; sendInput(data: string): void;
/** Refresh the header's name/command (e.g. after autoname renames the
* session) — the connection outlives mount/unmount, so this is the only
* way an already-open header picks up a new name. */
updateInfo(info: AttachInfo): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */ /** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void; dispose(): void;
} }
@@ -89,25 +96,21 @@ export function createAttachConnection(
el.className = "attach"; el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the // Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it. // tooltip for when you really need it.
const titleMarkup = (i?: AttachInfo): { html: string; attr: string } => ({ const titleHtml = info
html: i ? `<span class="at-name">${escapeHtml(info.name)}</span>` +
? `<span class="at-name">${escapeHtml(i.name)}</span>` + (info.command && info.command !== info.name ? commandFragment(info.command) : "")
(i.command && i.command !== i.name ? commandFragment(i.command) : "") : `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`, const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
attr: i ? `${i.name} · ${i.command} (${sessionId})` : sessionId,
});
const initialTitle = titleMarkup(info);
el.innerHTML = ` el.innerHTML = `
<header class="attach-header"> <header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button> <button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(initialTitle.attr)}">${initialTitle.html}</span> <span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span> <span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button> <button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header> </header>
<div class="attach-body"></div> <div class="attach-body"></div>
`; `;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement; const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
@@ -218,6 +221,15 @@ export function createAttachConnection(
} }
} }
// 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 —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// 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
// line at a time and stall), so map finger drags ourselves. Two routes: // line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback. // • normal buffer → term.scrollLines walks the scrollback.
@@ -367,9 +379,6 @@ export function createAttachConnection(
let ws: WebSocket; let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS; let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null; let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => { const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return; if (ws.readyState !== WebSocket.OPEN) return;
@@ -389,7 +398,6 @@ export function createAttachConnection(
}; };
function connect(): void { function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId)); ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
@@ -410,20 +418,10 @@ export function createAttachConnection(
return; return;
} }
if (ev.data instanceof ArrayBuffer) { if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay; term.write(new Uint8Array(ev.data));
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) { } else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer". // Some browsers may default to Blob despite binaryType="arraybuffer".
void ev.data.arrayBuffer().then((b) => { ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
} }
}; };
@@ -472,12 +470,6 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => { const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows }); sendControl({ type: "Resize", cols, rows });
}); });
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null; const visual = window.visualViewport ?? null;
@@ -521,11 +513,6 @@ export function createAttachConnection(
// The closure above: the same bytes-out path keystrokes and the mobile key // The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take. // bar already take.
sendInput, sendInput,
updateInfo(next: AttachInfo) {
const { html, attr } = titleMarkup(next);
titleEl.innerHTML = html;
titleEl.title = attr;
},
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
@@ -538,7 +525,6 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize);
dataDisp.dispose(); dataDisp.dispose();
resizeDisp.dispose(); resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl(); releaseWebgl();
try { try {
ws.close(); ws.close();
+4
View File
@@ -53,6 +53,10 @@ export function requestOpenCommandBar(): void {
* connection pool. */ * connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane"; export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session /** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
+14 -12
View File
@@ -133,28 +133,31 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2); expect(run).toHaveBeenCalledTimes(2);
}); });
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => { it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
const plain = vi.fn(); const plain = vi.fn();
const chorded = vi.fn(); const chorded = vi.fn();
const set = new CommandSet( const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER), defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
{ leader: DEFAULT_LEADER },
); );
const input = el("<input>"); const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false); expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled(); expect(plain).not.toHaveBeenCalled();
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1); expect(chorded).toHaveBeenCalledTimes(1);
}); });
it("leaves a plain Escape for the pane instead of binding it", () => { it("keeps pane commands live inside the terminal's textarea", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() })); const run = vi.fn();
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>'); const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, textarea, "Escape")).toBe(false); 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);
}); });
}); });
@@ -170,7 +173,6 @@ describe("the leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s])); const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]); expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]); expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched. // Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]); expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
}); });
+8 -2
View File
@@ -93,8 +93,7 @@ const SPECS: readonly SpecSource[] = [
id: "focus.toggle", id: "focus.toggle",
label: "Move the keyboard between the session list and the pane", label: "Move the keyboard between the session list and the pane",
group: "Session list", group: "Session list",
keys: [`${DEFAULT_LEADER} h`], keys: ["ctrl-shift-h"],
leaderKeys: (leader) => [`${leader} h`],
scope: "global", scope: "global",
}, },
{ {
@@ -146,6 +145,13 @@ const SPECS: readonly SpecSource[] = [
keys: ["tab"], keys: ["tab"],
scope: "list", 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", id: "help.toggle",
label: "Show or hide this help", label: "Show or hide this help",
-1
View File
@@ -16,7 +16,6 @@ vi.mock("./attach.js", () => ({
sendInput(data: string) { sendInput(data: string) {
sent.push({ id, data }); sent.push({ id, data });
}, },
updateInfo() {},
dispose() {}, dispose() {},
}), }),
})); }));
-1
View File
@@ -79,7 +79,6 @@ export class ConnectionPool {
if (conn) { if (conn) {
this.cache.delete(id); this.cache.delete(id);
this.cache.set(id, conn); this.cache.set(id, conn);
if (info) conn.updateInfo(info);
} else { } else {
conn = createAttachConnection( conn = createAttachConnection(
id, id,
-25
View File
@@ -130,31 +130,6 @@ describe("DaemonLink", () => {
expect(link.status().state).toBe("online"); expect(link.status().state).toBe("online");
}); });
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => { it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual(); const sched = manual();
const reload = vi.fn(); const reload = vi.fn();
-11
View File
@@ -148,17 +148,6 @@ export class DaemonLink {
void this.probe(); void this.probe();
} }
/**
* Recheck liveness after the browser resumes from the background. Mobile
* browsers may suspend both WebSocket events and timers while a PWA is
* hidden, leaving a stale `down` state after the daemon is already back.
*/
recover(): void {
if (this.state === "updating") return;
this.noteDisconnected();
void this.probe();
}
/** /**
* One `/api/info` round-trip. Resolves `true` when the daemon answered. * One `/api/info` round-trip. Resolves `true` when the daemon answered.
* *
-15
View File
@@ -132,21 +132,6 @@ describe("CatalogStore reducer", () => {
}); });
describe("CatalogStore reconnect", () => { describe("CatalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.start();
store.reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => { it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket()); const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets); const { store, scheduler } = storeWith(sockets);
-17
View File
@@ -59,23 +59,6 @@ export class CatalogStore {
this.connect(); this.connect();
} }
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// A close event from the superseded socket must not schedule a second
// connection alongside the new one.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */ /** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number { backoff(): number {
return this.backoffMs; return this.backoffMs;
-18
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { import {
emptyLayout, emptyLayout,
equalizeRatios,
leaves, leaves,
locateSession, locateSession,
openSessions, openSessions,
@@ -45,23 +44,6 @@ describe("splitLeaf", () => {
}); });
}); });
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => { describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => { it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1"); const tab = tabWithOneLeaf("s1");
-11
View File
@@ -118,17 +118,6 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null; tab.root = tab.root ? rec(tab.root) : null;
} }
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab { export function addTab(layout: Layout): Tab {
const tab: Tab = { const tab: Tab = {
id: freshId("t"), id: freshId("t"),
+11 -20
View File
@@ -35,6 +35,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js"; import { createPreviewSchedule } from "./previewSchedule.js";
import { import {
FOCUS_PANE_EVENT, FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT, OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT, OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT, SESSION_SELECT_EVENT,
@@ -107,19 +108,6 @@ void link.start();
store.start(); store.start();
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after a
// transient lost connection has already recovered.
function recoverFromBackground(): void {
if (document.visibilityState !== "visible") return;
link.recover();
store.reconnect();
}
document.addEventListener("visibilitychange", recoverFromBackground);
window.addEventListener("pageshow", recoverFromBackground);
window.addEventListener("online", recoverFromBackground);
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite. // without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) { if (import.meta.env.DEV && location.search.includes("mock")) {
@@ -452,11 +440,10 @@ const spawnKeys = new CommandSet(
}), }),
); );
// Leader-h moves the keyboard between the session list and the pane, the web // Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the // web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H // terminal owns every plain keystroke, so nothing unmodified could ever get
// is already claimed by the browser (history), so nothing unmodified — or // the focus back out of it.
// modified with a common browser chord — could get the focus back out of it.
// //
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served // This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off // on `/api/info`): it holds the key as a partial chord and takes select-all off
@@ -541,8 +528,8 @@ window.addEventListener(
); );
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle, // Tab in the session list hands the keyboard to the pane; Escape in the pane
// above) hands it back the other way. // hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => { document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops // Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs. // being provisional rather than snapping back the moment the list blurs.
@@ -550,6 +537,10 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed); if (previewed) focusSession(previewed);
pool.focusTerminal(); pool.focusTerminal();
}); });
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the /** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely * hash router and the sidebar's click-driven path (#133), which can't rely
-4
View File
@@ -10,7 +10,6 @@ import {
activeTab, activeTab,
addTab, addTab,
closeTab, closeTab,
equalizeRatios,
findLeaf, findLeaf,
freshId, freshId,
type Dir, type Dir,
@@ -458,7 +457,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview(); dropPreview();
const tab = activeTab(layout); const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render(); render();
} }
@@ -544,7 +542,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId; target.sessionId = sessionId;
} else { } else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
} }
render(); render();
opts.onOpenSessionsChange?.(); opts.onOpenSessionsChange?.();
@@ -818,7 +815,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self); if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id); else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after); tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render(); render();
} }