Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f801b12e87 |
Generated
-1
@@ -1051,7 +1051,6 @@ dependencies = [
|
|||||||
"maestro-cli",
|
"maestro-cli",
|
||||||
"maestro-daemon",
|
"maestro-daemon",
|
||||||
"maestro-tui",
|
"maestro-tui",
|
||||||
"tempfile",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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() {},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user