Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the 23 views it drove are gone; App.tsx composes the ported components — sidebar, multi-view, command bar, spawn modal, help and restart overlays, daemon link, drawer — around the catalog and layout stores. The pure parts of four deleted modules moved next to the components that still use them: the spawn-form model and spawnSimilar into components/spawn, the transcript's markdown/format helpers and the diff into components/structured, the help overlay's grouping into components/dialogs. Document-level event names now live in uiEvents.ts. Deduplicated two pairs the migration left side by side: the sidebar uses dialogs/KillDialog and common/ContextMenu. It also listens for the command bar's kill/rename events, which nothing had picked up since catalog.ts went away. styles.css stays as the token and base layer, with `#app` renamed to `#root` and the pane-body rules re-keyed off the classes the React panes actually carry. Closes #253
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
|
// list. Sessions and commands rank together by default; `>` narrows to
|
|
// commands, `~` to directories. `tab` on a session drills into its action page.
|
|
//
|
|
// The bar picks an action and hands off — it never reimplements one. Each
|
|
// handler defaults to the event the flow that already implements it listens
|
|
// on, so the React bar and the legacy shell raise the same flows.
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
import {
|
|
KILL_SESSION_EVENT,
|
|
RENAME_SESSION_EVENT,
|
|
SESSION_SELECT_EVENT,
|
|
SPAWN_SIMILAR_EVENT,
|
|
} from "../../uiEvents.js";
|
|
import { publishedCommands, type Command } from "../../commands.js";
|
|
import { sessionName, type Session } from "../../types.js";
|
|
import { ActionPage } from "./ActionPage.js";
|
|
import { ResultList } from "./ResultList.js";
|
|
import {
|
|
barGroups,
|
|
barRows,
|
|
commandBarCandidates,
|
|
dirEntryPaths,
|
|
directoryBarCandidates,
|
|
isActionCandidate,
|
|
listDirEntries as defaultListDirEntries,
|
|
loadBarDirectories,
|
|
parseBarQuery,
|
|
probeDir,
|
|
sessionActionCandidates,
|
|
sessionBarCandidates,
|
|
PLACEHOLDERS,
|
|
type BarCandidate,
|
|
type BarResult,
|
|
type DirectoryBarCandidate,
|
|
type SessionAction,
|
|
} from "./model.js";
|
|
|
|
/** Raised when Enter lands on a directory row and no handler was given. 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";
|
|
|
|
export interface CommandBarProps {
|
|
/** 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;
|
|
/** 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[]>;
|
|
onOpenSession?(sessionId: string): void;
|
|
onSpawnDirectory?(path: string): void;
|
|
onKillSession?(session: Session): void;
|
|
onRenameSession?(session: Session): void;
|
|
onSpawnSimilar?(session: Session): void;
|
|
/** Closing returns the keyboard to whatever held it when the bar opened. */
|
|
onClose(): void;
|
|
}
|
|
|
|
function raise(name: string, detail: unknown): void {
|
|
document.dispatchEvent(new CustomEvent(name, { detail }));
|
|
}
|
|
|
|
export function CommandBar(props: CommandBarProps) {
|
|
const {
|
|
sessions,
|
|
homeDir,
|
|
commands,
|
|
loadDirectories = loadBarDirectories,
|
|
listDirEntries = defaultListDirEntries,
|
|
onClose,
|
|
} = props;
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [raw, setRaw] = useState("");
|
|
const [highlighted, setHighlighted] = useState(0);
|
|
// The action page: `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.
|
|
const [page, setPage] = useState<Session | null>(null);
|
|
const [stashed, setStashed] = useState("");
|
|
|
|
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
|
// whatever `/api/dirs` has filled in for the directory being typed.
|
|
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
|
|
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
|
|
const requestedRef = useRef(false);
|
|
const listingsRef = useRef(new Map<string, string[]>());
|
|
|
|
// 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 = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
|
|
|
|
const sessionCandidates = useMemo(
|
|
() => sessionBarCandidates(sessions, homeDir),
|
|
[sessions, homeDir],
|
|
);
|
|
const commandCandidates = useMemo(
|
|
() => commandBarCandidates(commands ?? publishedCommands()),
|
|
[commands],
|
|
);
|
|
|
|
// Closing returns the keyboard to whatever held it when the bar opened: a
|
|
// round trip through the bar never silently moves focus.
|
|
useEffect(() => {
|
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
inputRef.current?.focus();
|
|
return () => {
|
|
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
|
};
|
|
}, []);
|
|
|
|
// The recents-and-projects list, the first time `~` is used. No directories
|
|
// is a shorter list, not an error state.
|
|
useEffect(() => {
|
|
if (parsed.sigil !== "~" || requestedRef.current) return;
|
|
requestedRef.current = true;
|
|
let live = true;
|
|
loadDirectories()
|
|
.then((paths) => {
|
|
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [parsed.sigil, loadDirectories, homeDir]);
|
|
|
|
// 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.
|
|
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
|
|
useEffect(() => {
|
|
if (dir === "") {
|
|
setProbed([]);
|
|
return;
|
|
}
|
|
const cached = listingsRef.current.get(dir);
|
|
if (cached) {
|
|
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
|
|
return;
|
|
}
|
|
let live = true;
|
|
setProbed([]);
|
|
listDirEntries(dir)
|
|
.then((entries) => {
|
|
listingsRef.current.set(dir, entries);
|
|
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
|
|
})
|
|
.catch(() => {
|
|
/* an unreadable directory contributes nothing */
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [dir, listDirEntries, homeDir]);
|
|
|
|
/** 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. */
|
|
const candidates: BarCandidate[] = useMemo(() => {
|
|
if (page) return sessionActionCandidates();
|
|
switch (parsed.sigil) {
|
|
case ">":
|
|
return [...commandCandidates];
|
|
case "~": {
|
|
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];
|
|
}
|
|
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
|
|
|
|
const groups = useMemo(
|
|
() => barGroups(barRows(parsed.query, candidates)),
|
|
[parsed.query, candidates],
|
|
);
|
|
// Grouping is a rendering of the ranking, so the flattened groups are the
|
|
// rows in the order they appear — which is the order the highlight walks and
|
|
// Enter acts on.
|
|
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
|
|
|
// The highlight starts on the globally best row every time the list changes:
|
|
// an old index would point at a different result.
|
|
useEffect(() => {
|
|
setHighlighted(0);
|
|
}, [rows]);
|
|
|
|
/** The highlight does not wrap: it stops at the first and last row. */
|
|
const move = useCallback(
|
|
(delta: number) => {
|
|
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
|
|
},
|
|
[rows.length],
|
|
);
|
|
|
|
const runAction = useCallback(
|
|
(action: SessionAction, session: Session) => {
|
|
onClose();
|
|
switch (action.id) {
|
|
case "kill":
|
|
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
|
|
return;
|
|
case "rename":
|
|
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
|
|
return;
|
|
case "spawn-similar":
|
|
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
|
|
return;
|
|
}
|
|
},
|
|
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
|
|
);
|
|
|
|
const activate = useCallback(
|
|
(index: number) => {
|
|
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;
|
|
}
|
|
onClose();
|
|
switch (candidate.kind) {
|
|
case "session":
|
|
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
|
|
candidate.session.id,
|
|
);
|
|
return;
|
|
case "command":
|
|
candidate.command.run();
|
|
return;
|
|
case "directory":
|
|
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
|
|
candidate.path,
|
|
);
|
|
return;
|
|
}
|
|
},
|
|
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
|
|
);
|
|
|
|
/** `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. */
|
|
const drillIn = useCallback(() => {
|
|
const candidate = rows[highlighted]?.candidate;
|
|
if (page || candidate?.kind !== "session") return;
|
|
setPage(candidate.session);
|
|
setStashed(raw);
|
|
setRaw("");
|
|
}, [rows, highlighted, page, raw]);
|
|
|
|
/** Escape, or backspace at an empty query: back to the result list with the
|
|
* query the drill-in was made from. */
|
|
const leavePage = useCallback(() => {
|
|
setPage(null);
|
|
setRaw(stashed);
|
|
setStashed("");
|
|
}, [stashed]);
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
switch (e.key) {
|
|
case "Escape":
|
|
e.preventDefault();
|
|
// On an action page Escape is one level up, not the way out.
|
|
if (page) leavePage();
|
|
else onClose();
|
|
return;
|
|
case "Tab":
|
|
e.preventDefault();
|
|
drillIn();
|
|
return;
|
|
case "Backspace":
|
|
// Ordinary editing, except at an empty query on the action page.
|
|
if (page && raw === "") {
|
|
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;
|
|
}
|
|
};
|
|
|
|
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Command bar"
|
|
onMouseDown={(e) => {
|
|
// A press on the scrim closes; one inside the panel is left alone.
|
|
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
role="combobox"
|
|
aria-expanded="true"
|
|
aria-controls="cmdbar-results"
|
|
aria-label="Search sessions, commands and directories"
|
|
aria-activedescendant={activeRowId}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
|
|
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
|
|
value={raw}
|
|
onChange={(e) => setRaw(e.currentTarget.value)}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
{page ? (
|
|
<ActionPage
|
|
session={page}
|
|
groups={groups}
|
|
highlighted={highlighted}
|
|
onActivate={activate}
|
|
/>
|
|
) : (
|
|
<ResultList
|
|
groups={groups}
|
|
// While a sigil is in force there is one kind, so the header is
|
|
// dropped.
|
|
showHeaders={parsed.sigil === null}
|
|
highlighted={highlighted}
|
|
onActivate={activate}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|