Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c272e0b1 | ||
|
|
624f6f19c8 | ||
|
|
08161ae042 | ||
|
|
1e3c7b294d | ||
|
|
49ab7e9c81 | ||
|
|
0cc25d7e1c |
@@ -111,16 +111,33 @@ impl WorktreeManager {
|
||||
// Clear registrations whose directory is gone first, so a
|
||||
// previously-leaked entry doesn't push this branch to a suffix.
|
||||
prune(base_repo)?;
|
||||
// Anything occupying the target — a tree we left behind, one the
|
||||
// operator made, an unrelated directory — is sidestepped with a
|
||||
// numeric suffix on the directory; the branch stays whatever was
|
||||
// requested (#77). The daemon never adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
let (branch, path) = if create_branch {
|
||||
// Worktrees are commonly abandoned without deleting their branch,
|
||||
// so a random handle can collide with one of its own past runs.
|
||||
// Unlike the `add_existing` arm below, the suffix goes on the
|
||||
// branch name itself — the path is derived from it anyway.
|
||||
let mut candidate = branch.to_string();
|
||||
let mut path = self.target_path(base_repo, &candidate);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
|
||||
n += 1;
|
||||
candidate = format!("{branch}-{n}");
|
||||
path = self.target_path(base_repo, &candidate);
|
||||
}
|
||||
(candidate, path)
|
||||
} else {
|
||||
// Existing branch: the name must stay exact, so only the
|
||||
// directory — anything occupying it, ours or the operator's — is
|
||||
// sidestepped with a numeric suffix (#77). The daemon never
|
||||
// adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
(branch.to_string(), path)
|
||||
};
|
||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||
// yet; the target itself must not.
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -129,10 +146,10 @@ impl WorktreeManager {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||
if create_branch {
|
||||
cmd.arg("-b").arg(branch).arg(&path);
|
||||
cmd.arg("-b").arg(&branch).arg(&path);
|
||||
} else {
|
||||
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||
cmd.arg(&path).arg(branch);
|
||||
cmd.arg(&path).arg(&branch);
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
check(out)?;
|
||||
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
||||
Some(candidate.to_path_buf())
|
||||
}
|
||||
|
||||
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
|
||||
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["show-ref", "--verify", "--quiet"])
|
||||
.arg(format!("refs/heads/{branch}"))
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
/// no longer exists.
|
||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
|
||||
@@ -497,6 +497,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A random handle can reuse the name of a branch left behind by an earlier,
|
||||
/// already-removed worktree — `git worktree add -b` refuses outright in that
|
||||
/// case, even though the directory is free. The daemon suffixes the branch
|
||||
/// itself (not just the directory) and both land on `-2`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn colliding_branch_name_gets_suffixed_too() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
|
||||
// No worktree directory in the way, but the branch already exists.
|
||||
run_git(&["branch", "handle"], base.path());
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "handle", true))
|
||||
.await
|
||||
.expect("spawn onto a taken branch name");
|
||||
|
||||
let suffixed = base.path().join("worktrees").join("handle-2");
|
||||
assert_eq!(
|
||||
s.cwd, suffixed,
|
||||
"the directory must follow the suffixed branch name"
|
||||
);
|
||||
assert!(suffixed.is_dir());
|
||||
|
||||
let registrations = git_worktree_list(base.path());
|
||||
assert!(
|
||||
registrations.iter().any(|l| l.contains("handle-2")),
|
||||
"the -2 branch's worktree must be registered: {registrations:?}"
|
||||
);
|
||||
|
||||
let branches = String::from_utf8(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base.path())
|
||||
.args(["branch", "--list"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
branches.contains("handle-2"),
|
||||
"the branch itself must be named handle-2: {branches}"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #77/#88: the target path holds a plain directory the daemon never made and
|
||||
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
||||
/// beside it and leaves every byte of it alone.
|
||||
|
||||
+4
-3
@@ -307,9 +307,10 @@ 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
|
||||
than jumping to the end of the list.
|
||||
|
||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||
`ctrl-w h`.
|
||||
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
|
||||
by default — and the same chord back): an attached terminal owns every plain
|
||||
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
|
||||
uses `ctrl-w h`.
|
||||
|
||||
Everything else is the same key on both clients:
|
||||
|
||||
|
||||
+12
-4
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
|
||||
back to a short hash of the branch.
|
||||
|
||||
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
||||
a free one. The suffix names the **directory only**. The branch is whatever was
|
||||
requested; a second worktree of the same branch is not what happens here, because
|
||||
git refuses to check the same branch out twice and the branch picker already
|
||||
marks branches that are checked out elsewhere.
|
||||
a free one.
|
||||
|
||||
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
|
||||
itself, and the directory follows since it is derived from the branch — a
|
||||
random handle can otherwise reuse the name of a branch some earlier, already
|
||||
removed worktree left behind, and `git worktree add -b` refuses outright even
|
||||
though the directory is free.
|
||||
|
||||
For an *existing* branch (`create_branch: false`) the name must stay exact, so
|
||||
only the directory is suffixed — a second worktree of the same branch is not
|
||||
what happens here, because git refuses to check the same branch out twice and
|
||||
the branch picker already marks branches that are checked out elsewhere.
|
||||
|
||||
### The `worktree_root` override
|
||||
|
||||
|
||||
+7
-20
@@ -4,8 +4,6 @@ import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { attachWsUrl } from "./routes.js";
|
||||
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
||||
import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||
@@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import type { ClientControl, Uuid } from "./types.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
|
||||
* rationed rather than handed to every connection. */
|
||||
const webglPolicy = new WebglPolicy();
|
||||
@@ -221,15 +210,6 @@ 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
|
||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||
// • normal buffer → term.scrollLines walks the scrollback.
|
||||
@@ -470,6 +450,12 @@ export function createAttachConnection(
|
||||
const resizeDisp = term.onResize(({ 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);
|
||||
const visual = window.visualViewport ?? null;
|
||||
@@ -525,6 +511,7 @@ export function createAttachConnection(
|
||||
visual?.removeEventListener("resize", onResize);
|
||||
dataDisp.dispose();
|
||||
resizeDisp.dispose();
|
||||
selectionDisp.dispose();
|
||||
releaseWebgl();
|
||||
try {
|
||||
ws.close();
|
||||
|
||||
@@ -53,10 +53,6 @@ export function requestOpenCommandBar(): void {
|
||||
* connection pool. */
|
||||
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
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
+12
-14
@@ -133,31 +133,28 @@ describe("CommandSet", () => {
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
||||
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
|
||||
const plain = vi.fn();
|
||||
const chorded = vi.fn();
|
||||
const set = new CommandSet(
|
||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
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);
|
||||
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
|
||||
// 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);
|
||||
});
|
||||
|
||||
it("keeps pane commands live inside the terminal's textarea", () => {
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
||||
preventDefault: false,
|
||||
});
|
||||
it("leaves a plain Escape for the pane instead of binding it", () => {
|
||||
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||
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);
|
||||
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +170,7 @@ describe("the leader", () => {
|
||||
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.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.
|
||||
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||
});
|
||||
|
||||
+2
-8
@@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [
|
||||
id: "focus.toggle",
|
||||
label: "Move the keyboard between the session list and the pane",
|
||||
group: "Session list",
|
||||
keys: ["ctrl-shift-h"],
|
||||
keys: [`${DEFAULT_LEADER} h`],
|
||||
leaderKeys: (leader) => [`${leader} h`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
@@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [
|
||||
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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
emptyLayout,
|
||||
equalizeRatios,
|
||||
leaves,
|
||||
locateSession,
|
||||
openSessions,
|
||||
@@ -44,6 +45,23 @@ 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", () => {
|
||||
it("collapses the parent split into the surviving sibling", () => {
|
||||
const tab = tabWithOneLeaf("s1");
|
||||
|
||||
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
|
||||
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 {
|
||||
const tab: Tab = {
|
||||
id: freshId("t"),
|
||||
|
||||
+7
-11
@@ -35,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
OPEN_COMMAND_BAR_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
@@ -440,10 +439,11 @@ const spawnKeys = new CommandSet(
|
||||
}),
|
||||
);
|
||||
|
||||
// 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
|
||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||
// the focus back out of it.
|
||||
// Leader-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 behind the
|
||||
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
|
||||
// is already claimed by the browser (history), so nothing unmodified — or
|
||||
// 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
|
||||
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||
@@ -528,8 +528,8 @@ window.addEventListener(
|
||||
);
|
||||
|
||||
|
||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
|
||||
// above) hands it back the other way.
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
||||
// being provisional rather than snapping back the moment the list blurs.
|
||||
@@ -537,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||
if (previewed) focusSession(previewed);
|
||||
pool.focusTerminal();
|
||||
});
|
||||
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
});
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
activeTab,
|
||||
addTab,
|
||||
closeTab,
|
||||
equalizeRatios,
|
||||
findLeaf,
|
||||
freshId,
|
||||
type Dir,
|
||||
@@ -457,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
dropPreview();
|
||||
const tab = activeTab(layout);
|
||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||
equalizeRatios(tab.root);
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -542,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
target.sessionId = sessionId;
|
||||
} else {
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||
equalizeRatios(tab.root);
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
@@ -815,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
||||
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
||||
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
||||
equalizeRatios(tab.root);
|
||||
render();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user