The match-and-rank algorithm is pure and both the legacy bar and the React port rank with it (#247).
810 lines
28 KiB
TypeScript
810 lines
28 KiB
TypeScript
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
|
// list, opened from the sidebar, closed with Escape.
|
|
//
|
|
// Three kinds live here: sessions and commands, ranked together by default,
|
|
// and directories, which need the `~` sigil. `tab` on a session drills into
|
|
// its action page (#151), which is the same overlay over three actions.
|
|
|
|
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
|
import {
|
|
KILL_SESSION_EVENT,
|
|
RENAME_SESSION_EVENT,
|
|
SESSION_SELECT_EVENT,
|
|
SPAWN_SIMILAR_EVENT,
|
|
} from "./catalog.js";
|
|
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
|
import {
|
|
matchAndRank,
|
|
type CandidateKind,
|
|
type CommandCandidate,
|
|
type DirectoryCandidate,
|
|
type MatchRange,
|
|
type MatchResult,
|
|
type SessionCandidate,
|
|
} from "./utils/matcher.js";
|
|
import { splitPath } from "./paths.js";
|
|
import { splitCommand } from "./toolIcon.js";
|
|
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
|
|
|
/** A matcher candidate that remembers what it came from, so a result can be
|
|
* acted on without a second lookup. The extra property is invisible to the
|
|
* matcher, which only ever reads `kind` and `fields`. */
|
|
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 };
|
|
|
|
/** One row of a session's action page. It is a `command` to the matcher — a
|
|
* single `label` field, ranked exactly like a registry command — and carries
|
|
* the action it stands for instead of a registry entry. */
|
|
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
|
|
|
export type BarCandidate =
|
|
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
|
|
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. */
|
|
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
|
kind: CandidateKind;
|
|
title: string;
|
|
results: MatchResult<C>[];
|
|
}
|
|
|
|
const KIND_TITLES: Record<CandidateKind, string> = {
|
|
session: "Sessions",
|
|
command: "Commands",
|
|
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 };
|
|
}
|
|
|
|
// ---- the session action page ----
|
|
|
|
/** The actions a session's action page offers, and no others: the bar picks
|
|
* one and hands off to the flow that already implements it
|
|
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
|
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
|
|
|
export interface SessionAction {
|
|
id: SessionActionId;
|
|
label: string;
|
|
}
|
|
|
|
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
|
{ id: "kill", label: "Kill session" },
|
|
{ id: "rename", label: "Rename session" },
|
|
{ id: "spawn-similar", label: "Spawn similar" },
|
|
];
|
|
|
|
/** The action page's candidates, in the order above — which is the order an
|
|
* empty query lists them in, since the matcher keeps input order. */
|
|
export function sessionActionCandidates(): ActionBarCandidate[] {
|
|
return SESSION_ACTIONS.map((action) => ({
|
|
kind: "command",
|
|
action,
|
|
fields: { label: action.label },
|
|
}));
|
|
}
|
|
|
|
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
|
* `command` kind, so what separates them is what they carry. */
|
|
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
|
return candidate.kind === "command" && "action" in candidate;
|
|
}
|
|
|
|
// ---- candidates ----
|
|
|
|
function focusedAt(s: Session): number | null {
|
|
const stamp = s.last_focused_at;
|
|
if (typeof stamp !== "string" || stamp === "") return null;
|
|
const ms = Date.parse(stamp);
|
|
return Number.isNaN(ms) ? null : ms;
|
|
}
|
|
|
|
function createdAt(s: Session): number {
|
|
const ms = Date.parse(s.created_at ?? "");
|
|
return Number.isNaN(ms) ? 0 : ms;
|
|
}
|
|
|
|
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
|
|
* focused first; a session that has never been focused sorts after every
|
|
* session that has, most recently created first among those; and a dead
|
|
* session sorts after every live one regardless of recency, because a session
|
|
* you cannot attach to is not where you just were.
|
|
*
|
|
* This is also the input order for a non-empty query, which is what the
|
|
* matcher's last tie-break preserves. */
|
|
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
|
return [...sessions].sort((a, b) => {
|
|
const deadA = a.status === "Dead" ? 1 : 0;
|
|
const deadB = b.status === "Dead" ? 1 : 0;
|
|
if (deadA !== deadB) return deadA - deadB;
|
|
|
|
const focusA = focusedAt(a);
|
|
const focusB = focusedAt(b);
|
|
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
|
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
|
|
|
return createdAt(b) - createdAt(a);
|
|
});
|
|
}
|
|
|
|
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
|
* table names, with the directory abbreviated the way the sidebar shows it. */
|
|
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
|
const treePath = sessionTreePath(s);
|
|
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
|
const tool = splitCommand(s.command).tool ?? undefined;
|
|
return {
|
|
kind: "session",
|
|
session: s,
|
|
fields: {
|
|
name: sessionName(s),
|
|
...(directory ? { directory } : {}),
|
|
...(s.command ? { command: s.command } : {}),
|
|
...(tool ? { tool } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Every session as a candidate, in the empty-query order. */
|
|
export function sessionBarCandidates(
|
|
sessions: readonly Session[],
|
|
homeDir?: string,
|
|
): SessionBarCandidate[] {
|
|
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 ----
|
|
|
|
/** 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,
|
|
* and groups appear in the order their best-scoring member does — so the first
|
|
* row under the first header is the globally best result. */
|
|
export function barGroups<C extends BarCandidate>(
|
|
results: readonly MatchResult<C>[],
|
|
): BarGroup<C>[] {
|
|
const groups: BarGroup<C>[] = [];
|
|
for (const result of results) {
|
|
const kind = result.candidate.kind;
|
|
let group = groups.find((g) => g.kind === kind);
|
|
if (!group) {
|
|
group = { kind, title: KIND_TITLES[kind], results: [] };
|
|
groups.push(group);
|
|
}
|
|
group.results.push(result);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
/** 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
|
|
* this list. */
|
|
export function barRows<C extends BarCandidate>(
|
|
query: string,
|
|
candidates: readonly C[],
|
|
): MatchResult<C>[] {
|
|
return matchAndRank(query, candidates);
|
|
}
|
|
|
|
// ---- rendering ----
|
|
|
|
/** Text with its matched ranges wrapped in `<mark>`. Ranges come from the
|
|
* matcher as `[start, end)` character pairs over the field that won. */
|
|
export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment {
|
|
const frag = document.createDocumentFragment();
|
|
let cursor = 0;
|
|
for (const [start, end] of ranges) {
|
|
if (start > cursor) frag.append(text.slice(cursor, start));
|
|
const mark = document.createElement("mark");
|
|
mark.className = "cmdbar-hit";
|
|
mark.textContent = text.slice(start, end);
|
|
frag.append(mark);
|
|
cursor = end;
|
|
}
|
|
if (cursor < text.length) frag.append(text.slice(cursor));
|
|
return frag;
|
|
}
|
|
|
|
/** The name line of a row: the session's name, the command's label, or the
|
|
* 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 {
|
|
if (result.candidate.kind !== "session") return undefined;
|
|
const fields = result.candidate.fields;
|
|
switch (result.field) {
|
|
case "directory":
|
|
return fields.directory;
|
|
case "command":
|
|
return fields.command;
|
|
case "tool":
|
|
return fields.tool;
|
|
default:
|
|
return fields.directory;
|
|
}
|
|
}
|
|
|
|
function renderRow(result: BarResult, index: number): HTMLElement {
|
|
const candidate = result.candidate;
|
|
const row = document.createElement("div");
|
|
row.className = "cmdbar-row";
|
|
row.setAttribute("role", "option");
|
|
row.id = `cmdbar-row-${index}`;
|
|
row.dataset.index = String(index);
|
|
row.dataset.kind = candidate.kind;
|
|
if (candidate.kind === "session") {
|
|
row.dataset.sessionId = candidate.session.id;
|
|
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
|
} else if (isActionCandidate(candidate)) {
|
|
row.dataset.actionId = candidate.action.id;
|
|
} else if (candidate.kind === "command") {
|
|
row.dataset.commandId = candidate.command.id;
|
|
} else {
|
|
row.dataset.path = candidate.path;
|
|
}
|
|
|
|
const name = document.createElement("span");
|
|
name.className = "cmdbar-row-name";
|
|
const nameText = rowName(candidate);
|
|
const nameMatched =
|
|
result.field === "name" || result.field === "label" || result.field === "path";
|
|
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
|
|
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" && !isActionCandidate(candidate)) {
|
|
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);
|
|
if (meta) {
|
|
const el = document.createElement("span");
|
|
el.className = "cmdbar-row-meta";
|
|
const highlighted = result.field !== undefined && result.field !== "name";
|
|
el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta);
|
|
row.appendChild(el);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
|
mixed: "Go to a session…",
|
|
">": "Run a command…",
|
|
"~": "Spawn in a directory…",
|
|
};
|
|
|
|
// ---- the overlay ----
|
|
|
|
export interface CommandBarOptions {
|
|
/** Sessions to build candidates from, as the store has them right now. */
|
|
sessions: readonly Session[];
|
|
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
|
homeDir?: string;
|
|
/** Open the highlighted session. Defaults to the shared open path — the
|
|
* sidebar's click event — which is what the contract requires; tests pass
|
|
* their own. */
|
|
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;
|
|
/** The action page's three actions (#151). Each defaults to raising the
|
|
* event the flow that already implements it listens on — the sidebar's
|
|
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
|
* seeded from the session. The bar picks the action and nothing more. */
|
|
onKillSession?: (session: Session) => void;
|
|
onRenameSession?: (session: Session) => void;
|
|
onSpawnSimilar?: (session: Session) => void;
|
|
}
|
|
|
|
interface OpenBar {
|
|
el: HTMLElement;
|
|
dispose: () => void;
|
|
}
|
|
|
|
let current: OpenBar | null = null;
|
|
|
|
export function isCommandBarOpen(): boolean {
|
|
return current !== null;
|
|
}
|
|
|
|
export function closeCommandBar(): void {
|
|
current?.dispose();
|
|
}
|
|
|
|
/** Mount the bar. Opening while one is up replaces it, so a stray second call
|
|
* can never stack two overlays. */
|
|
export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
|
closeCommandBar();
|
|
|
|
// Closing returns the keyboard to whatever held it when the bar opened: a
|
|
// round trip through the bar never silently moves focus.
|
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
const openSession =
|
|
opts.onOpenSession ??
|
|
((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 sessionEvent =
|
|
(name: string) =>
|
|
(session: Session): void => {
|
|
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
|
};
|
|
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
|
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
|
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
|
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
|
const listEntries =
|
|
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
|
|
|
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
|
|
opts.sessions,
|
|
opts.homeDir,
|
|
);
|
|
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
|
|
opts.commands ?? publishedCommands(),
|
|
);
|
|
|
|
const el = document.createElement("div");
|
|
el.className = "cmdbar-overlay";
|
|
el.setAttribute("role", "dialog");
|
|
el.setAttribute("aria-modal", "true");
|
|
el.setAttribute("aria-label", "Command bar");
|
|
|
|
const panel = document.createElement("div");
|
|
panel.className = "cmdbar-panel";
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.className = "cmdbar-input";
|
|
input.setAttribute("role", "combobox");
|
|
input.setAttribute("aria-expanded", "true");
|
|
input.setAttribute("aria-controls", "cmdbar-results");
|
|
input.setAttribute("aria-label", "Search sessions, commands and directories");
|
|
input.setAttribute("autocomplete", "off");
|
|
input.spellcheck = false;
|
|
input.placeholder = PLACEHOLDERS.mixed;
|
|
|
|
const results = document.createElement("div");
|
|
results.className = "cmdbar-results";
|
|
results.id = "cmdbar-results";
|
|
results.setAttribute("role", "listbox");
|
|
|
|
panel.append(input, results);
|
|
el.appendChild(panel);
|
|
document.body.appendChild(el);
|
|
|
|
let rows: BarResult[] = [];
|
|
let highlighted = 0;
|
|
|
|
// The action page (#151). `page` is the session being acted on, `stashed`
|
|
// the result-list query it was drilled into from — backing out restores it,
|
|
// so the round trip costs the operator nothing.
|
|
let page: Session | null = null;
|
|
let stashed = "";
|
|
let header: HTMLElement | null = null;
|
|
|
|
// `~`'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[] {
|
|
if (page) return sessionActionCandidates();
|
|
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 {
|
|
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
|
const active = Number(row.dataset.index) === highlighted;
|
|
row.classList.toggle("is-active", active);
|
|
row.setAttribute("aria-selected", active ? "true" : "false");
|
|
if (active) {
|
|
row.scrollIntoView({ block: "nearest" });
|
|
input.setAttribute("aria-activedescendant", row.id);
|
|
}
|
|
}
|
|
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
|
}
|
|
|
|
/** The line naming the session being acted on, so the target is never
|
|
* ambiguous. Present only while the action page is up. */
|
|
function paintHeader(): void {
|
|
header?.remove();
|
|
header = null;
|
|
if (!page) return;
|
|
header = document.createElement("div");
|
|
header.className = "cmdbar-page-header";
|
|
header.setAttribute("role", "presentation");
|
|
header.textContent = `Actions for "${sessionName(page)}"`;
|
|
panel.insertBefore(header, results);
|
|
}
|
|
|
|
function render(): void {
|
|
// On the action page the query line is over the three actions, so there is
|
|
// no sigil to read: `>` and `~` are ordinary characters there.
|
|
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
|
input.placeholder = page
|
|
? `Act on ${sessionName(page)}…`
|
|
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
|
paintHeader();
|
|
rows = barRows(parsed.query, candidatesFor(parsed));
|
|
// The highlight starts on the globally best row every time the list
|
|
// changes: an old index would point at a different result.
|
|
highlighted = 0;
|
|
results.replaceChildren();
|
|
|
|
if (rows.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "cmdbar-empty";
|
|
empty.textContent = "No matches";
|
|
results.appendChild(empty);
|
|
paintHighlight();
|
|
return;
|
|
}
|
|
|
|
let index = 0;
|
|
for (const group of barGroups(rows)) {
|
|
// While a sigil is in force there is one kind, so the header is
|
|
// dropped — and the action page is one kind too.
|
|
if (parsed.sigil === null && !page) {
|
|
const header = document.createElement("div");
|
|
header.className = "cmdbar-group-title";
|
|
header.setAttribute("role", "presentation");
|
|
header.textContent = group.title;
|
|
results.appendChild(header);
|
|
}
|
|
for (const result of group.results) {
|
|
const at = index;
|
|
const row = renderRow(result, at);
|
|
row.addEventListener("click", () => activate(at));
|
|
results.appendChild(row);
|
|
index += 1;
|
|
}
|
|
}
|
|
paintHighlight();
|
|
}
|
|
|
|
/** The highlight does not wrap: it stops at the first and last row. */
|
|
function move(delta: number): void {
|
|
if (rows.length === 0) return;
|
|
highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta));
|
|
paintHighlight();
|
|
}
|
|
|
|
/** `tab`: drill into the highlighted session's action page. No effect on any
|
|
* other kind, and none on the page itself, which has no session rows. */
|
|
function drillIn(): void {
|
|
const candidate = rows[highlighted]?.candidate;
|
|
if (page || candidate?.kind !== "session") return;
|
|
page = candidate.session;
|
|
stashed = input.value;
|
|
input.value = "";
|
|
render();
|
|
}
|
|
|
|
/** Escape, or backspace at an empty query: back to the result list with the
|
|
* query the drill-in was made from. */
|
|
function leavePage(): void {
|
|
page = null;
|
|
input.value = stashed;
|
|
stashed = "";
|
|
render();
|
|
}
|
|
|
|
function runAction(action: SessionAction, session: Session): void {
|
|
close();
|
|
switch (action.id) {
|
|
case "kill":
|
|
killSession(session);
|
|
return;
|
|
case "rename":
|
|
renameSession(session);
|
|
return;
|
|
case "spawn-similar":
|
|
spawnSimilar(session);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function activate(index: number): void {
|
|
const result = rows[index];
|
|
if (!result) return;
|
|
const candidate = result.candidate;
|
|
if (isActionCandidate(candidate)) {
|
|
// The page cannot be up without a session to act on.
|
|
if (page) runAction(candidate.action, page);
|
|
return;
|
|
}
|
|
close();
|
|
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 {
|
|
switch (e.key) {
|
|
case "Escape":
|
|
e.preventDefault();
|
|
// On an action page Escape is one level up, not the way out.
|
|
if (page) leavePage();
|
|
else close();
|
|
return;
|
|
case "Tab":
|
|
e.preventDefault();
|
|
drillIn();
|
|
return;
|
|
case "Backspace":
|
|
// Ordinary editing, except at an empty query on the action page.
|
|
if (page && input.value === "") {
|
|
e.preventDefault();
|
|
leavePage();
|
|
}
|
|
return;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
activate(highlighted);
|
|
return;
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
move(1);
|
|
return;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
move(-1);
|
|
return;
|
|
case "n":
|
|
if (e.ctrlKey) {
|
|
e.preventDefault();
|
|
move(1);
|
|
}
|
|
return;
|
|
case "p":
|
|
if (e.ctrlKey) {
|
|
e.preventDefault();
|
|
move(-1);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
function close(): void {
|
|
if (current?.el !== el) return;
|
|
current = null;
|
|
el.remove();
|
|
// Restoring focus is what makes the bar a view over the app rather than a
|
|
// place in it; a detached node cannot take it.
|
|
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
|
}
|
|
|
|
input.addEventListener("keydown", onKeydown);
|
|
input.addEventListener("input", render);
|
|
// A press on the scrim closes; one inside the panel is left alone.
|
|
el.addEventListener("mousedown", (ev) => {
|
|
if (!panel.contains(ev.target as Node)) close();
|
|
});
|
|
|
|
current = { el, dispose: close };
|
|
render();
|
|
input.focus();
|
|
return el;
|
|
}
|