Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2cbb23fe2 | ||
|
|
9fe9fe4628 | ||
|
|
e6a254b734 |
@@ -1,115 +0,0 @@
|
|||||||
# Task: Gitea issue #153 — the TUI command bar (action, overlay, session results)
|
|
||||||
|
|
||||||
You are an autonomous worker in a blitz over the **Command bar** milestone of
|
|
||||||
the `naps62/maestro` Gitea repo (`https://git.naps.pt`, `$GITEA_TOKEN` is in
|
|
||||||
`~/.env.claude` — `source` it).
|
|
||||||
|
|
||||||
Both of your dependencies are merged and on your base branch.
|
|
||||||
|
|
||||||
## Read first
|
|
||||||
|
|
||||||
1. **`docs/COMMAND-BAR.md`** — the contract. "Opening", "Structure", "Empty
|
|
||||||
query" and "Keys" are yours. Implement what it says.
|
|
||||||
2. The issue body:
|
|
||||||
`curl -sS -H "Authorization: token $GITEA_TOKEN" https://git.naps.pt/api/v1/repos/naps62/maestro/issues/153 | jq -r .body`
|
|
||||||
3. `CLAUDE.md` — conventions. **TDD by default**; `cargo fmt` and
|
|
||||||
`cargo clippy --all-targets -- -D warnings` before every commit.
|
|
||||||
4. What you build on, all merged:
|
|
||||||
- `crates/maestro-tui/src/matcher.rs` (#147) — the match-and-rank function
|
|
||||||
and its candidate types. **Do not reimplement matching or reorder its
|
|
||||||
results.**
|
|
||||||
- `crates/maestro-protocol` — `Session::last_focused_at` (#145), the
|
|
||||||
daemon's recency stamp.
|
|
||||||
- `crates/maestro-tui/src/`: `action.rs`, `keymap.rs`, `config.rs`
|
|
||||||
(`DEFAULT_NORMAL` / `DEFAULT_INSERT`), `state.rs` (`OverlayKind`),
|
|
||||||
`overlays.rs`, `app.rs` (`handle_overlay_input` and the dispatcher),
|
|
||||||
`text_input.rs` (the edit buffer the rename prompt already uses).
|
|
||||||
5. **`web/src/commandBar.ts`** (#149) — the web bar implementing this same
|
|
||||||
contract, already merged. Read it, especially `orderSessionsForBar`. Where
|
|
||||||
the doc leaves room, match what it does; the two clients disagreeing is the
|
|
||||||
failure this contract exists to prevent.
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
- **`Action::OpenCommandBar`** — new variant, with its `parse` arm, `Display`
|
|
||||||
arm, `help_label`, and dispatcher arm. `Action::parse` and `Display` must
|
|
||||||
round-trip, as the existing tests require of every variant.
|
|
||||||
- **Bind `ctrl-k` in both `DEFAULT_NORMAL` and `DEFAULT_INSERT`**, so it opens
|
|
||||||
from the sidebar and from inside a pane. Because it is an ordinary keymap
|
|
||||||
entry it stays rebindable, and the help overlay — which builds its lines from
|
|
||||||
the live keymap — picks it up with no second list.
|
|
||||||
- **A new `OverlayKind` variant**, routed in `handle_overlay_input` alongside
|
|
||||||
Spawn, KillConfirm, Rename and Help. Follow how `Rename` keeps its edit
|
|
||||||
buffer on the app rather than in the enum, so the overlay enum stays
|
|
||||||
comparable.
|
|
||||||
- **Empty query lists sessions, most recently focused first**, by
|
|
||||||
`last_focused_at`. The doc fixes what happens to never-focused and dead
|
|
||||||
sessions — follow it exactly, and match `orderSessionsForBar`.
|
|
||||||
- **Typing fuzzy-matches sessions** through `matcher.rs`, with matched ranges
|
|
||||||
highlighted in the rendered row. Build candidates from the fields the doc's
|
|
||||||
candidate table names, directory abbreviated as the sidebar shows it.
|
|
||||||
- **Results render under kind headers**, groups ordered by best-scoring member,
|
|
||||||
highlight starting on the globally best row. Only sessions exist as a kind
|
|
||||||
here, but build the grouping so #154 can add commands and directories without
|
|
||||||
restructuring it.
|
|
||||||
- **Enter attaches the highlighted session.** Escape closes. Up/down and
|
|
||||||
`ctrl-p`/`ctrl-n` move the highlight, which does not wrap; headers are never
|
|
||||||
landed on.
|
|
||||||
|
|
||||||
**Module name**: `palette.rs` is the colour palette. Do not put this there and
|
|
||||||
do not name your module `palette`.
|
|
||||||
|
|
||||||
## Non-goals — later issues own these, do not build them
|
|
||||||
|
|
||||||
- The `>` and `~` sigils (#154).
|
|
||||||
- Tab drilling into a session's action page (#155).
|
|
||||||
- The leader sending a literal Ctrl-K to the pane (#156).
|
|
||||||
|
|
||||||
Build the grouping and query line so those slot in without a rewrite, but do
|
|
||||||
not implement them.
|
|
||||||
|
|
||||||
## Ground rules
|
|
||||||
|
|
||||||
- You are on `command-bar/153-tui-bar`, cut from `origin/main`. **Merge
|
|
||||||
`origin/blitz/command-bar` first** (merge, never rebase) — everything you
|
|
||||||
depend on is there and not on `main`. Merge it again if it moves under you.
|
|
||||||
- Stay inside `crates/`. Do not touch `web/`, and do not modify `matcher.rs`;
|
|
||||||
if you believe it is wrong, comment on the issue instead.
|
|
||||||
- Integration tests use **real tmux** — don't mock it; isolate with a temp
|
|
||||||
`$HOME` and `TMUX_TMPDIR` the way the existing tests do. `overlays.rs` shows
|
|
||||||
how the existing overlays are tested against a `TestBackend`.
|
|
||||||
- Run the suite at most once at the end, through the gate:
|
|
||||||
`/home/naps62/.claude/skills/tracker-common/scripts/gate.sh -- cargo test --workspace`.
|
|
||||||
Exit 75 means no slot — a normal outcome, not a failure: say so and push
|
|
||||||
anyway. Exit 137 is the memory cap, not a test failure.
|
|
||||||
- Report the **aggregate test count**, not one crate's.
|
|
||||||
- Commit convention: `feat(maestro): ...`.
|
|
||||||
- Bugs or problems outside this issue's scope: **comment them on issue #153**,
|
|
||||||
do not fix them silently. Earlier workers' reports changed the scope of two
|
|
||||||
later issues before they started.
|
|
||||||
- Never wait for input.
|
|
||||||
|
|
||||||
## Exit criteria
|
|
||||||
|
|
||||||
Do not end your final reply until ALL of these are true:
|
|
||||||
|
|
||||||
1. `Action::OpenCommandBar` exists, round-trips through `parse`/`Display`, and
|
|
||||||
has a plain-language `help_label`.
|
|
||||||
2. `ctrl-k` is bound in both the Normal and Insert default keymaps and opens
|
|
||||||
the bar from each.
|
|
||||||
3. Empty query lists sessions by recency, with the doc's rules for
|
|
||||||
never-focused and dead sessions, matching `orderSessionsForBar`.
|
|
||||||
4. Typing ranks through `matcher.rs` with ranges highlighted, under kind
|
|
||||||
headers.
|
|
||||||
5. Enter attaches; Escape, arrows and `ctrl-p`/`ctrl-n` behave as the doc's key
|
|
||||||
table says.
|
|
||||||
6. `cargo fmt` clean, `cargo clippy --all-targets -- -D warnings` clean, suite
|
|
||||||
green (or the gate returned 75/137 and you said which).
|
|
||||||
7. Committed on `command-bar/153-tui-bar` and the branch **pushed** to
|
|
||||||
`origin`.
|
|
||||||
|
|
||||||
Do **not** merge into `blitz/command-bar` and do **not** close the issue — the
|
|
||||||
orchestrator does both.
|
|
||||||
|
|
||||||
If you catch yourself summarizing progress before those seven are true, you
|
|
||||||
have stopped too early — keep going.
|
|
||||||
@@ -50,6 +50,10 @@ export interface AttachConnection {
|
|||||||
onUnmount(): void;
|
onUnmount(): void;
|
||||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||||
focusTerminal(): void;
|
focusTerminal(): void;
|
||||||
|
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
|
||||||
|
* handling — the leader's literal Ctrl-K escape hatch
|
||||||
|
* (docs/COMMAND-BAR.md). */
|
||||||
|
sendInput(data: string): 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;
|
||||||
}
|
}
|
||||||
@@ -506,6 +510,9 @@ export function createAttachConnection(
|
|||||||
focusTerminal() {
|
focusTerminal() {
|
||||||
term.focus();
|
term.focus();
|
||||||
},
|
},
|
||||||
|
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||||
|
// bar already take.
|
||||||
|
sendInput,
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
+240
-5
@@ -6,13 +6,19 @@ import {
|
|||||||
barGroups,
|
barGroups,
|
||||||
barRows,
|
barRows,
|
||||||
closeCommandBar,
|
closeCommandBar,
|
||||||
|
commandBarCandidates,
|
||||||
|
dedupeCommands,
|
||||||
|
directoryBarCandidates,
|
||||||
|
directoryCandidate,
|
||||||
isCommandBarOpen,
|
isCommandBarOpen,
|
||||||
openCommandBar,
|
openCommandBar,
|
||||||
orderSessionsForBar,
|
orderSessionsForBar,
|
||||||
|
parseBarQuery,
|
||||||
sessionBarCandidates,
|
sessionBarCandidates,
|
||||||
sessionCandidate,
|
sessionCandidate,
|
||||||
|
SPAWN_DIRECTORY_EVENT,
|
||||||
} from "./commandBar.js";
|
} from "./commandBar.js";
|
||||||
import { COMMAND_SPECS } from "./commands.js";
|
import { COMMAND_SPECS, type Command } from "./commands.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
function session(id: string, over: Partial<Session> = {}): Session {
|
function session(id: string, over: Partial<Session> = {}): Session {
|
||||||
@@ -173,8 +179,12 @@ describe("the overlay", () => {
|
|||||||
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Sessions only: the mixed list's command half is the sigil describe's
|
||||||
|
// business, and the registry is whatever another test file happened to bind.
|
||||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
||||||
return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} });
|
return openCommandBar(
|
||||||
|
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("is a registry command, so the help overlay lists it", () => {
|
it("is a registry command, so the help overlay lists it", () => {
|
||||||
@@ -252,7 +262,7 @@ describe("the overlay", () => {
|
|||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||||
openCommandBar({ sessions, homeDir: "/home/me" });
|
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
|
||||||
press("ArrowDown");
|
press("ArrowDown");
|
||||||
press("Enter");
|
press("Enter");
|
||||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||||
@@ -262,7 +272,7 @@ describe("the overlay", () => {
|
|||||||
|
|
||||||
it("Enter on an empty result list does nothing", () => {
|
it("Enter on an empty result list does nothing", () => {
|
||||||
const onOpenSession = vi.fn();
|
const onOpenSession = vi.fn();
|
||||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||||
type("zzz");
|
type("zzz");
|
||||||
press("Enter");
|
press("Enter");
|
||||||
expect(onOpenSession).not.toHaveBeenCalled();
|
expect(onOpenSession).not.toHaveBeenCalled();
|
||||||
@@ -301,9 +311,234 @@ describe("the overlay", () => {
|
|||||||
|
|
||||||
it("clicking a row opens that session", () => {
|
it("clicking a row opens that session", () => {
|
||||||
const onOpenSession = vi.fn();
|
const onOpenSession = vi.fn();
|
||||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
||||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||||
expect(isCommandBarOpen()).toBe(false);
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- the sigils (#150) ----
|
||||||
|
|
||||||
|
function command(id: string, label: string, keys: string[], run = () => {}): Command {
|
||||||
|
return { id, label, group: "Test", keys, scope: "global", run };
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
function rowKeys(): string[][] {
|
||||||
|
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
|
||||||
|
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parsing the query line", () => {
|
||||||
|
it("reads a sigil only in first position", () => {
|
||||||
|
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
|
||||||
|
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
|
||||||
|
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||||
|
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||||
|
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
|
||||||
|
// The case that matters: the second `~` of a directory query reaches the
|
||||||
|
// matcher.
|
||||||
|
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||||
|
expect(parseBarQuery(">").sigil).toBe(">");
|
||||||
|
expect(parseBarQuery("").sigil).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("command candidates", () => {
|
||||||
|
it("dedupes by action, not by chord", () => {
|
||||||
|
const run = () => {};
|
||||||
|
const kill = command("a.kill", "Kill the session", ["x"], run);
|
||||||
|
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
|
||||||
|
const other = command("c.rename", "Rename the session", ["r"]);
|
||||||
|
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches on the label only, and keeps the chord for display", () => {
|
||||||
|
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
|
||||||
|
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
|
||||||
|
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("directory candidates", () => {
|
||||||
|
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||||
|
const c = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||||
|
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||||
|
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
|
||||||
|
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
|
||||||
|
"/home/me/dev/api",
|
||||||
|
"/tmp/scratch",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the sigils in the overlay", () => {
|
||||||
|
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
|
||||||
|
const ran: string[] = [];
|
||||||
|
const commands = [
|
||||||
|
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
|
||||||
|
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ran.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||||
|
return openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands,
|
||||||
|
onOpenSession: () => {},
|
||||||
|
loadDirectories: async () => [],
|
||||||
|
listDirEntries: async () => [],
|
||||||
|
onSpawnDirectory: () => {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||||
|
open();
|
||||||
|
type(">");
|
||||||
|
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
|
||||||
|
expect(rowKeys()).toEqual([["n"], ["x"]]);
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a command on its label and never on its chord", () => {
|
||||||
|
open();
|
||||||
|
type(">kill");
|
||||||
|
expect(rowNames()).toEqual(["Kill the highlighted session"]);
|
||||||
|
// `n` and `x` are chords, not label text: querying one finds nothing that
|
||||||
|
// the label does not also justify.
|
||||||
|
type(">ctrlx");
|
||||||
|
expect(rowNames()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the highlighted command on Enter", () => {
|
||||||
|
open();
|
||||||
|
type(">kill");
|
||||||
|
press("Enter");
|
||||||
|
expect(ran).toEqual(["list.kill"]);
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||||
|
open();
|
||||||
|
type("s");
|
||||||
|
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||||
|
"Sessions",
|
||||||
|
"Commands",
|
||||||
|
]);
|
||||||
|
type(">spawn");
|
||||||
|
expect(rowNames()).toEqual(["Open the spawn form"]);
|
||||||
|
// Backspacing the sigil away is ordinary editing.
|
||||||
|
type("spawn");
|
||||||
|
expect(rowNames()).toContain("Open the spawn form");
|
||||||
|
type("");
|
||||||
|
expect(rowNames()).toEqual(["api"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists recent dirs then projects under `~`", async () => {
|
||||||
|
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
|
||||||
|
type("~");
|
||||||
|
await flush();
|
||||||
|
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||||
|
const asked: string[] = [];
|
||||||
|
open({
|
||||||
|
loadDirectories: async () => ["/home/me/notes"],
|
||||||
|
listDirEntries: async (dir) => {
|
||||||
|
asked.push(dir);
|
||||||
|
return ["maestro", "api"];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The second `~` is a literal query character and reaches the matcher.
|
||||||
|
type("~~/dev/mae");
|
||||||
|
await flush();
|
||||||
|
expect(asked).toEqual(["~/dev/"]);
|
||||||
|
expect(rowNames()).toEqual(["~/dev/maestro"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns in the highlighted directory on Enter", async () => {
|
||||||
|
const spawned: string[] = [];
|
||||||
|
open({
|
||||||
|
loadDirectories: async () => ["~/dev/api"],
|
||||||
|
onSpawnDirectory: (path) => spawned.push(path),
|
||||||
|
});
|
||||||
|
type("~api");
|
||||||
|
await flush();
|
||||||
|
press("Enter");
|
||||||
|
expect(spawned).toEqual(["/home/me/dev/api"]);
|
||||||
|
expect(isCommandBarOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||||
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||||
|
openCommandBar({
|
||||||
|
sessions,
|
||||||
|
homeDir: "/home/me",
|
||||||
|
commands,
|
||||||
|
onOpenSession: () => {},
|
||||||
|
loadDirectories: async () => ["/home/me/dev/api"],
|
||||||
|
listDirEntries: async () => [],
|
||||||
|
});
|
||||||
|
type("~");
|
||||||
|
await flush();
|
||||||
|
press("Enter");
|
||||||
|
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||||
|
expect(seen).toEqual(["/home/me/dev/api"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the focus round-trip over a pane (#152)", () => {
|
||||||
|
function terminalTextarea(): HTMLTextAreaElement {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.className = "xterm-helper-textarea";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
return ta;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("hands the keyboard back to the terminal that had it", () => {
|
||||||
|
const ta = terminalTextarea();
|
||||||
|
ta.focus();
|
||||||
|
openCommandBar({ sessions: [], commands: [] });
|
||||||
|
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
|
||||||
|
press("Escape");
|
||||||
|
expect(document.activeElement).toBe(ta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no focus change to the daemon, opening or closing", () => {
|
||||||
|
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
try {
|
||||||
|
const ta = terminalTextarea();
|
||||||
|
ta.focus();
|
||||||
|
openCommandBar({ sessions: [], commands: [] });
|
||||||
|
press("Escape");
|
||||||
|
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
|
||||||
|
String(url).includes("/focus"),
|
||||||
|
);
|
||||||
|
expect(focusCalls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+316
-30
@@ -1,33 +1,47 @@
|
|||||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||||
// list, opened from the sidebar, closed with Escape.
|
// list, opened from the sidebar, closed with Escape.
|
||||||
//
|
//
|
||||||
// Sessions are the only kind here (#149); the sigils, the action page and the
|
// Three kinds live here: sessions and commands, ranked together by default,
|
||||||
// terminal-focus binding land in #150-#152.
|
// and directories, which need the `~` sigil. The action page and the
|
||||||
|
// terminal-focus binding land in #151-#152.
|
||||||
|
|
||||||
|
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||||
|
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||||
import {
|
import {
|
||||||
matchAndRank,
|
matchAndRank,
|
||||||
type CandidateKind,
|
type CandidateKind,
|
||||||
|
type CommandCandidate,
|
||||||
|
type DirectoryCandidate,
|
||||||
type MatchRange,
|
type MatchRange,
|
||||||
type MatchResult,
|
type MatchResult,
|
||||||
type SessionCandidate,
|
type SessionCandidate,
|
||||||
} from "./matcher.js";
|
} from "./matcher.js";
|
||||||
|
import { splitPath } from "./paths.js";
|
||||||
import { splitCommand } from "./toolIcon.js";
|
import { splitCommand } from "./toolIcon.js";
|
||||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||||
|
|
||||||
/** A matcher candidate that remembers which session it came from, so a result
|
/** A matcher candidate that remembers what it came from, so a result can be
|
||||||
* can be acted on without a second lookup. The extra property is invisible to
|
* acted on without a second lookup. The extra property is invisible to the
|
||||||
* the matcher, which only ever reads `kind` and `fields`. */
|
* matcher, which only ever reads `kind` and `fields`. */
|
||||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||||
|
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||||
|
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||||
|
* ranks; `path` is the one a spawn is made in. */
|
||||||
|
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||||
|
|
||||||
export type BarCandidate = SessionBarCandidate;
|
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
|
||||||
export type BarResult = MatchResult<BarCandidate>;
|
export type BarResult = MatchResult<BarCandidate>;
|
||||||
|
|
||||||
|
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||||
|
* shell owns the spawn, which is the same fast-forward path `N` takes. */
|
||||||
|
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||||
|
|
||||||
/** One kind's slice of the ranked list, in rank order. */
|
/** One kind's slice of the ranked list, in rank order. */
|
||||||
export interface BarGroup {
|
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||||
kind: CandidateKind;
|
kind: CandidateKind;
|
||||||
title: string;
|
title: string;
|
||||||
results: BarResult[];
|
results: MatchResult<C>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||||
@@ -36,6 +50,29 @@ const KIND_TITLES: Record<CandidateKind, string> = {
|
|||||||
directory: "Directories",
|
directory: "Directories",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- the sigils ----
|
||||||
|
|
||||||
|
export type BarSigil = ">" | "~";
|
||||||
|
|
||||||
|
export interface BarQuery {
|
||||||
|
/** The sigil in force, or null for the mixed list. */
|
||||||
|
sigil: BarSigil | null;
|
||||||
|
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a raw query line into its sigil and the query proper.
|
||||||
|
*
|
||||||
|
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||||
|
* literal query character, which is what a directory query with a second `~`
|
||||||
|
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||||
|
* the first character goes and the query is a mixed one again. */
|
||||||
|
export function parseBarQuery(raw: string): BarQuery {
|
||||||
|
const first = raw[0];
|
||||||
|
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||||
|
return { sigil: null, query: raw };
|
||||||
|
}
|
||||||
|
|
||||||
// ---- candidates ----
|
// ---- candidates ----
|
||||||
|
|
||||||
function focusedAt(s: Session): number | null {
|
function focusedAt(s: Session): number | null {
|
||||||
@@ -99,14 +136,92 @@ export function sessionBarCandidates(
|
|||||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||||
|
* action — the registry already carries its aliases on one entry, but nothing
|
||||||
|
* stops two entries running the same handler, and listed naively that is two
|
||||||
|
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||||
|
* displayed, so a command with two of them is still one row. */
|
||||||
|
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||||
|
const seenRun = new Set<Command["run"]>();
|
||||||
|
const seenLabel = new Set<string>();
|
||||||
|
const kept: Command[] = [];
|
||||||
|
for (const command of commands) {
|
||||||
|
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||||
|
seenRun.add(command.run);
|
||||||
|
seenLabel.add(command.label);
|
||||||
|
kept.push(command);
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||||
|
* field: the chord is displayed beside the row, never matched, because
|
||||||
|
* matching it would make `>` results depend on a user's rebinds. */
|
||||||
|
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||||
|
return dedupeCommands(commands).map((command) => ({
|
||||||
|
kind: "command",
|
||||||
|
command,
|
||||||
|
fields: { label: command.label },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||||
|
* a path the operator typed or `/api/dirs` completed. */
|
||||||
|
export function expandHome(path: string, homeDir?: string): string {
|
||||||
|
if (!homeDir) return path;
|
||||||
|
if (path === "~") return homeDir;
|
||||||
|
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||||
|
const absolute = expandHome(path, homeDir);
|
||||||
|
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||||
|
* first mention of each — recent dirs (newest first) ahead of the project
|
||||||
|
* list, which is the empty-query order the contract asks for. */
|
||||||
|
export function directoryBarCandidates(
|
||||||
|
paths: readonly string[],
|
||||||
|
homeDir?: string,
|
||||||
|
): DirectoryBarCandidate[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: DirectoryBarCandidate[] = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
if (path === "") continue;
|
||||||
|
const candidate = directoryCandidate(path, homeDir);
|
||||||
|
if (seen.has(candidate.path)) continue;
|
||||||
|
seen.add(candidate.path);
|
||||||
|
out.push(candidate);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The directory list the bar starts from: what the spawn form already knows —
|
||||||
|
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||||
|
* fails. */
|
||||||
|
export async function loadBarDirectories(): Promise<string[]> {
|
||||||
|
const [recents, discovered] = await Promise.all([
|
||||||
|
recentDirs()
|
||||||
|
.then((r) => r.dirs.map((d) => d.cwd))
|
||||||
|
.catch(() => [] as string[]),
|
||||||
|
fetchProjects()
|
||||||
|
.then((list) => list.map((p) => p.path))
|
||||||
|
.catch(() => [] as string[]),
|
||||||
|
]);
|
||||||
|
return [...recents, ...discovered];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- grouping ----
|
// ---- grouping ----
|
||||||
|
|
||||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||||
* and groups appear in the order their best-scoring member does — so the first
|
* and groups appear in the order their best-scoring member does — so the first
|
||||||
* row under the first header is the globally best result. */
|
* row under the first header is the globally best result. */
|
||||||
export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
export function barGroups<C extends BarCandidate>(
|
||||||
const groups: BarGroup[] = [];
|
results: readonly MatchResult<C>[],
|
||||||
|
): BarGroup<C>[] {
|
||||||
|
const groups: BarGroup<C>[] = [];
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const kind = result.candidate.kind;
|
const kind = result.candidate.kind;
|
||||||
let group = groups.find((g) => g.kind === kind);
|
let group = groups.find((g) => g.kind === kind);
|
||||||
@@ -122,7 +237,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
|||||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||||
* this list. */
|
* this list. */
|
||||||
export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] {
|
export function barRows<C extends BarCandidate>(
|
||||||
|
query: string,
|
||||||
|
candidates: readonly C[],
|
||||||
|
): MatchResult<C>[] {
|
||||||
return matchAndRank(query, candidates);
|
return matchAndRank(query, candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +263,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]):
|
|||||||
return frag;
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The secondary line under a row's name: the field the match was found in when
|
/** The name line of a row: the session's name, the command's label, or the
|
||||||
* that was not the name, so a session found through its directory says so. */
|
* directory's path. */
|
||||||
|
function rowName(candidate: BarCandidate): string {
|
||||||
|
switch (candidate.kind) {
|
||||||
|
case "session":
|
||||||
|
return candidate.fields.name;
|
||||||
|
case "command":
|
||||||
|
return candidate.fields.label;
|
||||||
|
case "directory":
|
||||||
|
return candidate.fields.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The secondary line under a session row: the field the match was found in
|
||||||
|
* when that was not the name, so a session found through its directory says
|
||||||
|
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||||
|
* directory's path is already the name. */
|
||||||
function metaText(result: BarResult): string | undefined {
|
function metaText(result: BarResult): string | undefined {
|
||||||
|
if (result.candidate.kind !== "session") return undefined;
|
||||||
const fields = result.candidate.fields;
|
const fields = result.candidate.fields;
|
||||||
switch (result.field) {
|
switch (result.field) {
|
||||||
case "directory":
|
case "directory":
|
||||||
@@ -162,22 +296,44 @@ function metaText(result: BarResult): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(result: BarResult, index: number): HTMLElement {
|
function renderRow(result: BarResult, index: number): HTMLElement {
|
||||||
|
const candidate = result.candidate;
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "cmdbar-row";
|
row.className = "cmdbar-row";
|
||||||
row.setAttribute("role", "option");
|
row.setAttribute("role", "option");
|
||||||
row.id = `cmdbar-row-${index}`;
|
row.id = `cmdbar-row-${index}`;
|
||||||
row.dataset.index = String(index);
|
row.dataset.index = String(index);
|
||||||
row.dataset.sessionId = result.candidate.session.id;
|
row.dataset.kind = candidate.kind;
|
||||||
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
|
if (candidate.kind === "session") {
|
||||||
|
row.dataset.sessionId = candidate.session.id;
|
||||||
|
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||||
|
} else if (candidate.kind === "command") {
|
||||||
|
row.dataset.commandId = candidate.command.id;
|
||||||
|
} else {
|
||||||
|
row.dataset.path = candidate.path;
|
||||||
|
}
|
||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = "cmdbar-row-name";
|
name.className = "cmdbar-row-name";
|
||||||
const nameText = result.candidate.fields.name;
|
const nameText = rowName(candidate);
|
||||||
name.append(
|
const nameMatched =
|
||||||
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
|
result.field === "name" || result.field === "label" || result.field === "path";
|
||||||
);
|
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
|
||||||
row.appendChild(name);
|
row.appendChild(name);
|
||||||
|
|
||||||
|
// The chord is teaching material, not a match target: it is rendered from
|
||||||
|
// the live registry entry and never fed to the matcher.
|
||||||
|
if (candidate.kind === "command") {
|
||||||
|
const keys = document.createElement("span");
|
||||||
|
keys.className = "cmdbar-row-keys";
|
||||||
|
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||||
|
const kbd = document.createElement("kbd");
|
||||||
|
kbd.textContent = cap;
|
||||||
|
keys.appendChild(kbd);
|
||||||
|
}
|
||||||
|
row.appendChild(keys);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
const meta = metaText(result);
|
const meta = metaText(result);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
const el = document.createElement("span");
|
const el = document.createElement("span");
|
||||||
@@ -189,6 +345,12 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||||
|
mixed: "Go to a session…",
|
||||||
|
">": "Run a command…",
|
||||||
|
"~": "Spawn in a directory…",
|
||||||
|
};
|
||||||
|
|
||||||
// ---- the overlay ----
|
// ---- the overlay ----
|
||||||
|
|
||||||
export interface CommandBarOptions {
|
export interface CommandBarOptions {
|
||||||
@@ -200,6 +362,18 @@ export interface CommandBarOptions {
|
|||||||
* sidebar's click event — which is what the contract requires; tests pass
|
* sidebar's click event — which is what the contract requires; tests pass
|
||||||
* their own. */
|
* their own. */
|
||||||
onOpenSession?: (sessionId: string) => void;
|
onOpenSession?: (sessionId: string) => void;
|
||||||
|
/** The command registry to list under `>`. Defaults to every bound command,
|
||||||
|
* in registry order. */
|
||||||
|
commands?: readonly Command[];
|
||||||
|
/** The directory list behind `~`, fetched once on first use. */
|
||||||
|
loadDirectories?: () => Promise<string[]>;
|
||||||
|
/** One directory's subdirectories, for filling the list in as a path is
|
||||||
|
* typed. Defaults to `GET /api/dirs`. */
|
||||||
|
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||||
|
/** Spawn a session in the highlighted directory. Defaults to raising
|
||||||
|
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||||
|
* spawn. */
|
||||||
|
onSpawnDirectory?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenBar {
|
interface OpenBar {
|
||||||
@@ -228,8 +402,21 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
const openSession =
|
const openSession =
|
||||||
opts.onOpenSession ??
|
opts.onOpenSession ??
|
||||||
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
||||||
|
const spawnDirectory =
|
||||||
|
opts.onSpawnDirectory ??
|
||||||
|
((path: string) =>
|
||||||
|
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||||
|
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||||
|
const listEntries =
|
||||||
|
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||||
|
|
||||||
const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
|
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
|
||||||
|
opts.sessions,
|
||||||
|
opts.homeDir,
|
||||||
|
);
|
||||||
|
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
|
||||||
|
opts.commands ?? publishedCommands(),
|
||||||
|
);
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "cmdbar-overlay";
|
el.className = "cmdbar-overlay";
|
||||||
@@ -246,10 +433,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
input.setAttribute("role", "combobox");
|
input.setAttribute("role", "combobox");
|
||||||
input.setAttribute("aria-expanded", "true");
|
input.setAttribute("aria-expanded", "true");
|
||||||
input.setAttribute("aria-controls", "cmdbar-results");
|
input.setAttribute("aria-controls", "cmdbar-results");
|
||||||
input.setAttribute("aria-label", "Search sessions");
|
input.setAttribute("aria-label", "Search sessions, commands and directories");
|
||||||
input.setAttribute("autocomplete", "off");
|
input.setAttribute("autocomplete", "off");
|
||||||
input.spellcheck = false;
|
input.spellcheck = false;
|
||||||
input.placeholder = "Go to a session…";
|
input.placeholder = PLACEHOLDERS.mixed;
|
||||||
|
|
||||||
const results = document.createElement("div");
|
const results = document.createElement("div");
|
||||||
results.className = "cmdbar-results";
|
results.className = "cmdbar-results";
|
||||||
@@ -263,6 +450,90 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
let rows: BarResult[] = [];
|
let rows: BarResult[] = [];
|
||||||
let highlighted = 0;
|
let highlighted = 0;
|
||||||
|
|
||||||
|
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||||
|
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||||
|
let directories: DirectoryBarCandidate[] = [];
|
||||||
|
let directoriesRequested = false;
|
||||||
|
let probed: DirectoryBarCandidate[] = [];
|
||||||
|
let probedDir: string | null = null;
|
||||||
|
const listings = new Map<string, string[]>();
|
||||||
|
|
||||||
|
function alive(): boolean {
|
||||||
|
return current?.el === el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the recents-and-projects list the first time `~` is used. */
|
||||||
|
function ensureDirectories(): void {
|
||||||
|
if (directoriesRequested) return;
|
||||||
|
directoriesRequested = true;
|
||||||
|
loadDirectories()
|
||||||
|
.then((paths) => {
|
||||||
|
directories = directoryBarCandidates(paths, opts.homeDir);
|
||||||
|
if (alive()) render();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* no directories is a shorter list, not an error state */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
|
||||||
|
* the directory it names is listed and its children join the candidates, so
|
||||||
|
* `~` reaches a directory that was never spawned in. */
|
||||||
|
function probeDirectories(query: string): void {
|
||||||
|
const { dir } = splitPath(query);
|
||||||
|
if (dir === "") {
|
||||||
|
probed = [];
|
||||||
|
probedDir = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dir === probedDir) return;
|
||||||
|
probedDir = dir;
|
||||||
|
const cached = listings.get(dir);
|
||||||
|
if (cached) {
|
||||||
|
probed = directoryBarCandidates(
|
||||||
|
cached.map((entry) => `${dir}${entry}`),
|
||||||
|
opts.homeDir,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probed = [];
|
||||||
|
listEntries(dir)
|
||||||
|
.then((entries) => {
|
||||||
|
listings.set(dir, entries);
|
||||||
|
if (!alive() || probedDir !== dir) return;
|
||||||
|
probed = directoryBarCandidates(
|
||||||
|
entries.map((entry) => `${dir}${entry}`),
|
||||||
|
opts.homeDir,
|
||||||
|
);
|
||||||
|
render();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* an unreadable directory contributes nothing */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||||
|
* list is mixed — sessions and commands together — except on an empty query,
|
||||||
|
* where the bar is a session switcher. */
|
||||||
|
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||||
|
switch (parsed.sigil) {
|
||||||
|
case ">":
|
||||||
|
return [...commandCandidates];
|
||||||
|
case "~": {
|
||||||
|
ensureDirectories();
|
||||||
|
probeDirectories(parsed.query);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return [...directories, ...probed].filter((c) =>
|
||||||
|
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return parsed.query === ""
|
||||||
|
? [...sessionCandidates]
|
||||||
|
: [...sessionCandidates, ...commandCandidates];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function paintHighlight(): void {
|
function paintHighlight(): void {
|
||||||
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
||||||
const active = Number(row.dataset.index) === highlighted;
|
const active = Number(row.dataset.index) === highlighted;
|
||||||
@@ -277,7 +548,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
rows = barRows(input.value, candidates);
|
const parsed = parseBarQuery(input.value);
|
||||||
|
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||||
|
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||||
// The highlight starts on the globally best row every time the list
|
// The highlight starts on the globally best row every time the list
|
||||||
// changes: an old index would point at a different result.
|
// changes: an old index would point at a different result.
|
||||||
highlighted = 0;
|
highlighted = 0;
|
||||||
@@ -294,11 +567,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
for (const group of barGroups(rows)) {
|
for (const group of barGroups(rows)) {
|
||||||
const header = document.createElement("div");
|
// While a sigil is in force there is one kind, so the header is dropped.
|
||||||
header.className = "cmdbar-group-title";
|
if (parsed.sigil === null) {
|
||||||
header.setAttribute("role", "presentation");
|
const header = document.createElement("div");
|
||||||
header.textContent = group.title;
|
header.className = "cmdbar-group-title";
|
||||||
results.appendChild(header);
|
header.setAttribute("role", "presentation");
|
||||||
|
header.textContent = group.title;
|
||||||
|
results.appendChild(header);
|
||||||
|
}
|
||||||
for (const result of group.results) {
|
for (const result of group.results) {
|
||||||
const at = index;
|
const at = index;
|
||||||
const row = renderRow(result, at);
|
const row = renderRow(result, at);
|
||||||
@@ -320,9 +596,19 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|||||||
function activate(index: number): void {
|
function activate(index: number): void {
|
||||||
const result = rows[index];
|
const result = rows[index];
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const sessionId = result.candidate.session.id;
|
const candidate = result.candidate;
|
||||||
close();
|
close();
|
||||||
openSession(sessionId);
|
switch (candidate.kind) {
|
||||||
|
case "session":
|
||||||
|
openSession(candidate.session.id);
|
||||||
|
return;
|
||||||
|
case "command":
|
||||||
|
candidate.command.run();
|
||||||
|
return;
|
||||||
|
case "directory":
|
||||||
|
spawnDirectory(candidate.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent): void {
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
DEFAULT_LEADER,
|
DEFAULT_LEADER,
|
||||||
canonicalizeKey,
|
canonicalizeKey,
|
||||||
defineCommands,
|
defineCommands,
|
||||||
|
isBarTypingTarget,
|
||||||
|
isTerminalTextarea,
|
||||||
isTypingTarget,
|
isTypingTarget,
|
||||||
keyCaps,
|
keyCaps,
|
||||||
publishedCommands,
|
publishedCommands,
|
||||||
@@ -234,3 +236,74 @@ describe("isTypingTarget", () => {
|
|||||||
expect(isTypingTarget(null)).toBe(false);
|
expect(isTypingTarget(null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isTerminalTextarea", () => {
|
||||||
|
it("recognises xterm's offscreen textarea, and nothing else", () => {
|
||||||
|
expect(isTerminalTextarea(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(true);
|
||||||
|
expect(isTerminalTextarea(el("<textarea></textarea>"))).toBe(false);
|
||||||
|
expect(isTerminalTextarea(el("<input>"))).toBe(false);
|
||||||
|
expect(isTerminalTextarea(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBarTypingTarget", () => {
|
||||||
|
it("holds the bar's keys out of real form fields", () => {
|
||||||
|
expect(isBarTypingTarget(el("<input>"))).toBe(true);
|
||||||
|
expect(isBarTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => {
|
||||||
|
expect(isBarTypingTarget(el('<textarea class="xterm-helper-textarea"></textarea>'))).toBe(false);
|
||||||
|
expect(isBarTypingTarget(el("<button></button>"))).toBe(false);
|
||||||
|
expect(isBarTypingTarget(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctrl-k over a focused pane (#152)", () => {
|
||||||
|
const terminal = () => el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
|
||||||
|
/** The global listener's routing: the bar's set only sees a key that is not
|
||||||
|
* aimed at a real form field. */
|
||||||
|
function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) {
|
||||||
|
if (isBarTypingTarget(target)) return false;
|
||||||
|
return pressOn(set, target, k, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("opens the bar when the terminal has the keyboard", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
||||||
|
expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true);
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the spawn form's inputs their own Ctrl-K", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
const set = new CommandSet(defineCommands({ "bar.open": open }));
|
||||||
|
expect(route(set, el("<input>"), "k", { ctrlKey: true })).toBe(false);
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the literal 0x0B after the leader, without opening the bar", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
const literal = vi.fn();
|
||||||
|
const bar = new CommandSet(defineCommands({ "bar.open": open }));
|
||||||
|
const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), {
|
||||||
|
leader: DEFAULT_LEADER,
|
||||||
|
});
|
||||||
|
const target = terminal();
|
||||||
|
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||||
|
const e = key("k", { ctrlKey: true });
|
||||||
|
Object.defineProperty(e, "target", { value: target });
|
||||||
|
expect(set.handle(e)).toBe(true);
|
||||||
|
expect(literal).toHaveBeenCalledTimes(1);
|
||||||
|
// The leader's set consumed the key, so the bar's set never sees it.
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds the escape hatch behind the default leader", () => {
|
||||||
|
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK");
|
||||||
|
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+20
-5
@@ -32,6 +32,10 @@ export interface Command extends CommandSpec {
|
|||||||
run: () => void;
|
run: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
||||||
|
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
||||||
|
export const DEFAULT_LEADER = "ctrl-a";
|
||||||
|
|
||||||
/** Every command the web client has. The single list this issue exists to
|
/** Every command the web client has. The single list this issue exists to
|
||||||
* create: labels, chords and grouping live here and nowhere else. */
|
* create: labels, chords and grouping live here and nowhere else. */
|
||||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||||
@@ -56,6 +60,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
|||||||
keys: ["ctrl-k"],
|
keys: ["ctrl-k"],
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pane.literalCtrlK",
|
||||||
|
label: "Send a literal Ctrl-K to the pane",
|
||||||
|
group: "Pane",
|
||||||
|
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
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",
|
||||||
@@ -241,11 +252,19 @@ export function isTypingTarget(target: EventTarget | null): boolean {
|
|||||||
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
/** xterm's offscreen textarea. It is a typing target like any other, but it is
|
||||||
* also where the leader has to work — a pane is exactly where the operator
|
* also where the leader has to work — a pane is exactly where the operator
|
||||||
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
* needs the escape hatch (docs/COMMAND-BAR.md). */
|
||||||
function isTerminalTextarea(target: EventTarget | null): boolean {
|
export function isTerminalTextarea(target: EventTarget | null): boolean {
|
||||||
const el = target as HTMLElement | null;
|
const el = target as HTMLElement | null;
|
||||||
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True where the command bar's own keys must stay out of the way: a real form
|
||||||
|
* field, and not the pane. `ctrl-k` is intercepted ahead of xterm
|
||||||
|
* (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while
|
||||||
|
* the spawn form's inputs keep their own Ctrl-K. */
|
||||||
|
export function isBarTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
return isTypingTarget(target) && !isTerminalTextarea(target);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- display ----
|
// ---- display ----
|
||||||
|
|
||||||
const CAPS: Record<string, string> = {
|
const CAPS: Record<string, string> = {
|
||||||
@@ -292,10 +311,6 @@ export function keyCaps(chord: string): string[] {
|
|||||||
|
|
||||||
// ---- dispatch ----
|
// ---- dispatch ----
|
||||||
|
|
||||||
/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all
|
|
||||||
* in a browser, so a set that owns the leader `preventDefault`s it. */
|
|
||||||
export const DEFAULT_LEADER = "ctrl-a";
|
|
||||||
|
|
||||||
/** How long a partial chord waits for its next key — the TUI's
|
/** How long a partial chord waits for its next key — the TUI's
|
||||||
* `chord_timeout_ms` default. */
|
* `chord_timeout_ms` default. */
|
||||||
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
|
|
||||||
|
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
||||||
|
// the pool reaches the focused connection with them.
|
||||||
|
const sent: { id: string; data: string }[] = [];
|
||||||
|
vi.mock("./attach.js", () => ({
|
||||||
|
createAttachConnection: (id: string) => ({
|
||||||
|
sessionId: id,
|
||||||
|
el: document.createElement("div"),
|
||||||
|
onMount() {},
|
||||||
|
onUnmount() {},
|
||||||
|
focusTerminal() {},
|
||||||
|
sendInput(data: string) {
|
||||||
|
sent.push({ id, data });
|
||||||
|
},
|
||||||
|
dispose() {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ConnectionPool.sendToFocused", () => {
|
||||||
|
it("types the bytes into the focused session's PTY", () => {
|
||||||
|
mockFetch();
|
||||||
|
sent.length = 0;
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
const pane = document.createElement("div");
|
||||||
|
pool.mount(A, pane);
|
||||||
|
pool.setFocus(A);
|
||||||
|
|
||||||
|
expect(pool.sendToFocused("\x0b")).toBe(true);
|
||||||
|
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op with nothing focused", () => {
|
||||||
|
mockFetch();
|
||||||
|
sent.length = 0;
|
||||||
|
const pool = new ConnectionPool();
|
||||||
|
|
||||||
|
expect(pool.sendToFocused("\x0b")).toBe(false);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -159,6 +159,18 @@ export class ConnectionPool {
|
|||||||
this.cache.get(this.focusedId)?.focusTerminal();
|
this.cache.get(this.focusedId)?.focusTerminal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Type raw bytes into the focused session's PTY. Returns false when there is
|
||||||
|
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
|
||||||
|
* is a no-op over the welcome pane rather than an error
|
||||||
|
* (docs/COMMAND-BAR.md). */
|
||||||
|
sendToFocused(data: string): boolean {
|
||||||
|
if (this.focusedId === null) return false;
|
||||||
|
const conn = this.cache.get(this.focusedId);
|
||||||
|
if (!conn) return false;
|
||||||
|
conn.sendInput(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fully disconnect + forget a session. */
|
/** Fully disconnect + forget a session. */
|
||||||
close(id: Uuid): void {
|
close(id: Uuid): void {
|
||||||
const conn = this.cache.get(id);
|
const conn = this.cache.get(id);
|
||||||
|
|||||||
+65
-13
@@ -7,8 +7,8 @@ import { ConnectionPool } from "./connections.js";
|
|||||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
|
import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js";
|
||||||
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
|
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
||||||
import { DaemonLink } from "./daemonLink.js";
|
import { DaemonLink } from "./daemonLink.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import { installRestartOverlay } from "./restartOverlay.js";
|
import { installRestartOverlay } from "./restartOverlay.js";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
fetchNames,
|
fetchNames,
|
||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
|
gitRoot,
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
@@ -337,6 +338,16 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void
|
|||||||
// a new branch named after that name. Anything unresolvable (no projects
|
// a new branch named after that name. Anything unresolvable (no projects
|
||||||
// known yet, no command, no generated name) opens the form pre-filled with
|
// known yet, no command, no generated name) opens the form pre-filled with
|
||||||
// what did resolve instead.
|
// what did resolve instead.
|
||||||
|
async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||||
|
try {
|
||||||
|
const info = await getDaemonInfo();
|
||||||
|
if (info.presets?.length) return info.presets;
|
||||||
|
} catch {
|
||||||
|
/* keep fallbacks */
|
||||||
|
}
|
||||||
|
return FALLBACK_PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
async function requestFastForward(): Promise<void> {
|
async function requestFastForward(): Promise<void> {
|
||||||
const [discovered, name] = await Promise.all([
|
const [discovered, name] = await Promise.all([
|
||||||
fetchProjects().catch(() => [] as Project[]),
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
@@ -344,13 +355,7 @@ async function requestFastForward(): Promise<void> {
|
|||||||
.then((batch) => batch.names[0])
|
.then((batch) => batch.names[0])
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
const presets = await spawnPresets();
|
||||||
try {
|
|
||||||
const info = await getDaemonInfo();
|
|
||||||
if (info.presets?.length) presets = info.presets;
|
|
||||||
} catch {
|
|
||||||
/* keep fallbacks */
|
|
||||||
}
|
|
||||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||||
const req = buildFastForwardRequest(resolved);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
@@ -372,13 +377,54 @@ async function requestFastForward(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
||||||
|
// fast-forward spawn, aimed at the chosen directory instead of the most recent
|
||||||
|
// project. A directory the project scan never saw stands in as its own project;
|
||||||
|
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||||
|
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||||
|
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||||
|
const [discovered, name] = await Promise.all([
|
||||||
|
fetchProjects().catch(() => [] as Project[]),
|
||||||
|
fetchNames(1)
|
||||||
|
.then((batch) => batch.names[0])
|
||||||
|
.catch(() => undefined),
|
||||||
|
]);
|
||||||
|
const presets = await spawnPresets();
|
||||||
|
const known = discovered.find((p) => p.path === path);
|
||||||
|
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||||
|
const resolved = resolveFastForward([project], presets, name, path);
|
||||||
|
const req = buildFastForwardRequest(resolved);
|
||||||
|
if (req) {
|
||||||
|
const inRepo =
|
||||||
|
known !== undefined ||
|
||||||
|
(await gitRoot(path)
|
||||||
|
.then((r) => !!r.root)
|
||||||
|
.catch(() => false));
|
||||||
|
if (!inRepo) delete req.worktree;
|
||||||
|
try {
|
||||||
|
mountSpawned(await createSession(req));
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// A failed POST degrades to the pre-filled form, never a dead row.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openSpawnModal({
|
||||||
|
seed: fastForwardSeed(resolved),
|
||||||
|
onSuccess: (session) => mountSpawned(session),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
|
||||||
|
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
|
||||||
|
});
|
||||||
|
|
||||||
// The global half of the command registry (#148). Two sets, because they
|
// The global half of the command registry (#148). Two sets, because they
|
||||||
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
||||||
// the plain keys go deaf behind it while the focus chord does not — an
|
// the plain keys go deaf behind it while the focus chord does not — an
|
||||||
// attached terminal must never be able to trap the focus keys.
|
// attached terminal must never be able to trap the focus keys.
|
||||||
//
|
//
|
||||||
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
|
||||||
// keys, so `isTypingTarget` keeps them out of form fields and the terminal's
|
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
|
||||||
// hidden textarea.
|
// hidden textarea.
|
||||||
const spawnKeys = new CommandSet(
|
const spawnKeys = new CommandSet(
|
||||||
defineCommands({
|
defineCommands({
|
||||||
@@ -405,6 +451,11 @@ const focusKeys = new CommandSet(
|
|||||||
sidebar.focusList();
|
sidebar.focusList();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line
|
||||||
|
// away from every pane, and leader ctrl-k gives it back as the literal
|
||||||
|
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the
|
||||||
|
// bar's, so the chord never opens the bar.
|
||||||
|
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
||||||
}),
|
}),
|
||||||
{ leader: DEFAULT_LEADER },
|
{ leader: DEFAULT_LEADER },
|
||||||
);
|
);
|
||||||
@@ -433,9 +484,10 @@ window.addEventListener(
|
|||||||
if (focusKeys.handle(e)) return;
|
if (focusKeys.handle(e)) return;
|
||||||
// The help overlay owns the keyboard while it is up.
|
// The help overlay owns the keyboard while it is up.
|
||||||
if (isHelpOpen()) return;
|
if (isHelpOpen()) return;
|
||||||
// Opening the bar over a focused terminal is #152; until then the pane's
|
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
|
||||||
// hidden textarea keeps its Ctrl+K, like any other typing target.
|
// seen ahead of xterm and opens the bar (#152). A real form field — the
|
||||||
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
|
// spawn form's inputs — keeps its own Ctrl+K.
|
||||||
|
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
|
||||||
spawnKeys.handle(e);
|
spawnKeys.handle(e);
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -1517,6 +1517,25 @@ input.leaf-rename:focus {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A command row's chord, shown so the bar teaches the keymap. */
|
||||||
|
.cmdbar-row-keys {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmdbar-row-keys kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
padding: 0 var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
.cmdbar-hit {
|
.cmdbar-hit {
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
Reference in New Issue
Block a user