Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb1d7ddbe | ||
|
|
9d6b69cc49 | ||
|
|
b2cbb23fe2 | ||
|
|
9fe9fe4628 | ||
|
|
e6a254b734 | ||
|
|
dc783778c8 |
@@ -202,6 +202,41 @@ pub fn command_program(command: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
||||
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
||||
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
||||
const BY_PROGRAM: [(&str, &str); 17] = [
|
||||
("claude", "claude"),
|
||||
("claude-code", "claude"),
|
||||
("codex", "codex"),
|
||||
("opencode", "opencode"),
|
||||
("gemini", "gemini"),
|
||||
("aider", "aider"),
|
||||
("cursor", "cursor"),
|
||||
("cursor-agent", "cursor"),
|
||||
("pi", "pi"),
|
||||
("bash", "shell"),
|
||||
("zsh", "shell"),
|
||||
("sh", "shell"),
|
||||
("fish", "shell"),
|
||||
("dash", "shell"),
|
||||
("ksh", "shell"),
|
||||
("nu", "shell"),
|
||||
("xonsh", "shell"),
|
||||
];
|
||||
|
||||
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
||||
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
|
||||
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
|
||||
/// the same tools the web's `splitCommand().tool` does.
|
||||
pub fn command_tool(command: &str) -> Option<&'static str> {
|
||||
let program = command_program(command).to_lowercase();
|
||||
BY_PROGRAM
|
||||
.iter()
|
||||
.find(|(name, _)| *name == program)
|
||||
.map(|(_, tool)| *tool)
|
||||
}
|
||||
|
||||
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||
fn is_env_assignment(word: &str) -> bool {
|
||||
let mut chars = word.chars();
|
||||
@@ -476,4 +511,22 @@ mod tests {
|
||||
assert_eq!(command_program("sudo"), "sudo");
|
||||
assert_eq!(command_program(""), "");
|
||||
}
|
||||
|
||||
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
||||
/// tools the web's `splitCommand().tool` does.
|
||||
#[test]
|
||||
fn command_tool_names_the_mark_the_card_shows() {
|
||||
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
||||
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
||||
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
||||
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
||||
}
|
||||
|
||||
/// Narrow on purpose: an unrecognised program has no mark, so the
|
||||
/// candidate has no `tool` field to match rather than a wrong one.
|
||||
#[test]
|
||||
fn command_tool_is_absent_for_an_unrecognised_program() {
|
||||
assert_eq!(command_tool("hx src/main.rs"), None);
|
||||
assert_eq!(command_tool(""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ pub enum Action {
|
||||
RenameHighlighted,
|
||||
|
||||
// Chrome
|
||||
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
||||
/// over the sessions, ranked by the shared matcher.
|
||||
OpenCommandBar,
|
||||
ToggleSidebar,
|
||||
ShowHelp,
|
||||
Quit,
|
||||
@@ -94,6 +97,7 @@ impl Action {
|
||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||
Action::KillHighlighted => "Kill selected session".into(),
|
||||
Action::RenameHighlighted => "Rename selected session".into(),
|
||||
Action::OpenCommandBar => "Go to a session".into(),
|
||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||
Action::ShowHelp => "Show help".into(),
|
||||
Action::Quit => "Quit Maestro".into(),
|
||||
@@ -120,6 +124,7 @@ impl Action {
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||
"Quit" => return Ok(Action::Quit),
|
||||
@@ -239,10 +244,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The command bar is an ordinary keymap entry, so it must round-trip
|
||||
/// like every other action (#153).
|
||||
#[test]
|
||||
fn open_command_bar_round_trips() {
|
||||
assert_eq!(
|
||||
Action::parse("OpenCommandBar").unwrap(),
|
||||
Action::OpenCommandBar
|
||||
);
|
||||
assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar");
|
||||
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_round_trips() {
|
||||
for a in [
|
||||
Action::EnterInsert,
|
||||
Action::OpenCommandBar,
|
||||
Action::RenameHighlighted,
|
||||
Action::Quit,
|
||||
Action::OpenSpawnModal,
|
||||
|
||||
+534
-13
@@ -23,6 +23,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::catalog::CatalogStore;
|
||||
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
|
||||
use crate::config::TuiConfig;
|
||||
use crate::daemon_link::DaemonLink;
|
||||
use crate::draft::{DraftInputResult, DraftPane};
|
||||
@@ -78,6 +79,9 @@ pub struct App {
|
||||
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||
/// same reason the worktree entries are.
|
||||
rename_input: TextInput,
|
||||
/// Query buffer and result list behind [`OverlayKind::CommandBar`] (#153),
|
||||
/// kept here for the same reason the rename buffer is.
|
||||
command_bar: Option<CommandBar>,
|
||||
region: RegionMap,
|
||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||
clicks: ClickTracker,
|
||||
@@ -164,6 +168,14 @@ pub enum Effect {
|
||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||
/// spawn with no form; unresolvable defaults open a pre-filled form.
|
||||
FastForward,
|
||||
/// The same fast path pinned to one directory — the command bar's `~`
|
||||
/// (#154). Nothing may override the directory the operator named.
|
||||
FastForwardIn(PathBuf),
|
||||
/// Fetch the command bar's `~` candidates: recent dirs and the project
|
||||
/// list. `bar` guards a load landing in a bar that was reopened since.
|
||||
LoadBarDirectories {
|
||||
bar: u64,
|
||||
},
|
||||
/// The form's target changed: probe `/api/gitroot` so the worktree
|
||||
/// section knows its base repo (#65). `seq` guards stale answers.
|
||||
ProbeGitRoot {
|
||||
@@ -210,6 +222,7 @@ impl App {
|
||||
link,
|
||||
http,
|
||||
rename_input: TextInput::default(),
|
||||
command_bar: None,
|
||||
draft_tx,
|
||||
draft_rx,
|
||||
region: RegionMap::default(),
|
||||
@@ -381,6 +394,8 @@ impl App {
|
||||
Effect::Kill(id) => self.submit_kill(id).await,
|
||||
Effect::LoadDraft => self.spawn_load_draft(),
|
||||
Effect::FastForward => self.spawn_fast_forward(),
|
||||
Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir),
|
||||
Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar),
|
||||
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
|
||||
Effect::LoadBranches => self.spawn_load_branches(),
|
||||
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||
@@ -430,6 +445,42 @@ impl App {
|
||||
(OverlayDecision::Consumed, Vec::new())
|
||||
}
|
||||
},
|
||||
// The bar owns every key while it is up: the keymap must not see
|
||||
// `j`, `x` or `q` as commands mid-query.
|
||||
Some(OverlayKind::CommandBar) => {
|
||||
let outcome = match self.command_bar.as_mut() {
|
||||
Some(bar) => bar.handle_key(key),
|
||||
None => BarOutcome::Close,
|
||||
};
|
||||
match outcome {
|
||||
BarOutcome::Stay => (OverlayDecision::Consumed, Vec::new()),
|
||||
BarOutcome::Close => {
|
||||
self.close_command_bar();
|
||||
(OverlayDecision::Consumed, Vec::new())
|
||||
}
|
||||
BarOutcome::Open(BarTarget::Session(sid)) => {
|
||||
self.close_command_bar();
|
||||
// The same open path the sidebar's own attach uses, so
|
||||
// a bar attach can never leave the layout in a state a
|
||||
// highlight-and-enter could not produce.
|
||||
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
|
||||
(
|
||||
OverlayDecision::Consumed,
|
||||
self.dispatch(Action::AttachHighlighted),
|
||||
)
|
||||
}
|
||||
// The bar picks the action and hands off; it never
|
||||
// reimplements one.
|
||||
BarOutcome::Open(BarTarget::Command(action)) => {
|
||||
self.close_command_bar();
|
||||
(OverlayDecision::Consumed, self.dispatch(action))
|
||||
}
|
||||
BarOutcome::Open(BarTarget::Directory(dir)) => {
|
||||
self.close_command_bar();
|
||||
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(OverlayKind::Help) => {
|
||||
self.state.dismiss_overlay();
|
||||
(OverlayDecision::Consumed, Vec::new())
|
||||
@@ -475,6 +526,7 @@ impl App {
|
||||
};
|
||||
self.state.show_overlay(OverlayKind::Help);
|
||||
}
|
||||
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
||||
Action::OpenSpawnModal => {
|
||||
self.open_spawn_modal();
|
||||
effects.push(Effect::LoadDraft);
|
||||
@@ -537,6 +589,10 @@ impl App {
|
||||
/// highlight already sitting on the attached session — clears at once.
|
||||
fn refresh_preview(&mut self) {
|
||||
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
||||
// The bar is a view over the app, not a place in it: while it is
|
||||
// up the sidebar highlight behind it previews nothing
|
||||
// (`docs/COMMAND-BAR.md`, "Focus round-trip").
|
||||
_ if self.state.overlay() == Some(&OverlayKind::CommandBar) => None,
|
||||
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
||||
_ => None,
|
||||
};
|
||||
@@ -588,6 +644,34 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the command bar up over whatever is behind it (#153). Opening
|
||||
/// while a session is previewed discards the preview first, like any
|
||||
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
|
||||
/// so closing gives the keyboard back to whatever held it.
|
||||
fn open_command_bar(&mut self) -> Vec<Effect> {
|
||||
let snapshot = self.catalog.snapshot();
|
||||
// The keymap for the mode the bar was opened from, which is the one
|
||||
// the operator would be typing into behind it. Same source the help
|
||||
// overlay reads, so the two can never disagree.
|
||||
let keymap = self.cfg.keymap.entries(self.state.mode);
|
||||
let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir);
|
||||
let id = bar.id();
|
||||
self.command_bar = Some(bar);
|
||||
self.pending_preview = None;
|
||||
self.clear_preview();
|
||||
self.state.show_overlay(OverlayKind::CommandBar);
|
||||
// `~`'s candidates are a network round trip; the bar opens without
|
||||
// them and fills in when they land.
|
||||
vec![Effect::LoadBarDirectories { bar: id }]
|
||||
}
|
||||
|
||||
/// Take the bar down, dropping its query. Every exit goes through here so
|
||||
/// a stale buffer can never outlive the overlay.
|
||||
fn close_command_bar(&mut self) {
|
||||
self.command_bar = None;
|
||||
self.state.dismiss_overlay();
|
||||
}
|
||||
|
||||
fn clear_preview(&mut self) {
|
||||
if self.state.preview.take().is_some() {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
@@ -935,18 +1019,27 @@ impl App {
|
||||
projects,
|
||||
names,
|
||||
focused_cwd,
|
||||
pinned,
|
||||
} = update
|
||||
{
|
||||
self.fast_forward_spawn(info, projects, names, focused_cwd)
|
||||
self.fast_forward_spawn(info, projects, names, focused_cwd, pinned)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// Addressed to the bar, not to a form: a load that outlived its bar is
|
||||
// dropped rather than filling in the next one.
|
||||
if let DraftUpdate::BarDirectories { bar, paths } = update {
|
||||
if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) {
|
||||
open.set_directories(&paths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let want = match &update {
|
||||
DraftUpdate::Data { draft, .. }
|
||||
| DraftUpdate::GitRoot { draft, .. }
|
||||
| DraftUpdate::Branches { draft, .. } => *draft,
|
||||
// Handled above; it targets no particular draft.
|
||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||
// Handled above; they target no particular draft.
|
||||
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(),
|
||||
};
|
||||
// Match by identity: dismissing the form and opening another while a
|
||||
// load is in flight must not let the old answer land in the new form.
|
||||
@@ -981,7 +1074,9 @@ impl App {
|
||||
DraftUpdate::Branches { cwd, branches, .. } => {
|
||||
draft.set_branches(&cwd, branches);
|
||||
}
|
||||
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
if follow_up_branches {
|
||||
self.spawn_load_branches();
|
||||
@@ -1009,16 +1104,52 @@ impl App {
|
||||
projects: projects.ok(),
|
||||
names,
|
||||
focused_cwd,
|
||||
pinned: None,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The same fast path, pinned to the directory the command bar's `~` row
|
||||
/// named (#154). Off the loop for the same reason as [`Self::
|
||||
/// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`.
|
||||
fn spawn_fast_forward_in(&self, dir: PathBuf) {
|
||||
let (daemon, http, base, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
||||
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
||||
let _ = tx.send(DraftUpdate::FastForward {
|
||||
info: info.ok(),
|
||||
projects: projects.ok(),
|
||||
names,
|
||||
focused_cwd: None,
|
||||
pinned: Some(dir),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs
|
||||
/// (newest first) ahead of the project list. Either failing is a shorter
|
||||
/// list, not an error state.
|
||||
fn spawn_load_bar_directories(&self, bar: u64) {
|
||||
let daemon = self.daemon.clone();
|
||||
let tx = self.draft_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects());
|
||||
let mut paths: Vec<PathBuf> = recents
|
||||
.map(|r| r.dirs.into_iter().map(|d| d.cwd).collect())
|
||||
.unwrap_or_default();
|
||||
paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path));
|
||||
let _ = tx.send(DraftUpdate::BarDirectories { bar, paths });
|
||||
});
|
||||
}
|
||||
|
||||
async fn fast_forward_spawn(
|
||||
&mut self,
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
focused_cwd: Option<PathBuf>,
|
||||
pinned: Option<PathBuf>,
|
||||
) {
|
||||
// Keep the home dir: without it the pre-filled draft renders
|
||||
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
||||
@@ -1032,13 +1163,21 @@ impl App {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let discovered = projects.unwrap_or_default();
|
||||
let resolved = crate::fast_forward::resolve(
|
||||
&discovered,
|
||||
&presets,
|
||||
names.first().cloned(),
|
||||
Utc::now(),
|
||||
focused_cwd.as_deref(),
|
||||
);
|
||||
// A pinned directory is the operator's own pick, so it takes no
|
||||
// fallback: a directory outside the project scan resolves no project,
|
||||
// which is an unresolvable default and opens the form seeded with it.
|
||||
let resolved = match pinned.as_deref() {
|
||||
Some(dir) => {
|
||||
crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned())
|
||||
}
|
||||
None => crate::fast_forward::resolve(
|
||||
&discovered,
|
||||
&presets,
|
||||
names.first().cloned(),
|
||||
Utc::now(),
|
||||
focused_cwd.as_deref(),
|
||||
),
|
||||
};
|
||||
if let Some(req) = resolved.build_request() {
|
||||
self.submit_spawn(req).await;
|
||||
return;
|
||||
@@ -1050,8 +1189,10 @@ impl App {
|
||||
};
|
||||
draft.home_dir = home_dir;
|
||||
// The fast path resolved its own project; its pick wins over the
|
||||
// focused session's, so the seed never lands on top of it.
|
||||
draft.seed_cwd = None;
|
||||
// focused session's, so the seed never lands on top of it. A pinned
|
||||
// directory is the exception: it is the seed, whether or not the scan
|
||||
// knew it as a project.
|
||||
draft.seed_cwd = pinned;
|
||||
if !presets.is_empty() {
|
||||
draft.set_presets(presets);
|
||||
}
|
||||
@@ -1222,6 +1363,7 @@ impl App {
|
||||
draft: self.draft.as_ref(),
|
||||
help: &self.help,
|
||||
rename_input: &self.rename_input,
|
||||
command_bar: self.command_bar.as_ref(),
|
||||
link: &link_status,
|
||||
};
|
||||
|
||||
@@ -1407,7 +1549,12 @@ enum DraftUpdate {
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
focused_cwd: Option<PathBuf>,
|
||||
/// The command bar pinned the spawn to this directory (#154): nothing
|
||||
/// — not the focused session, not the recency ranking — may move it.
|
||||
pinned: Option<PathBuf>,
|
||||
},
|
||||
/// The command bar's `~` candidates arrived (#154).
|
||||
BarDirectories { bar: u64, paths: Vec<PathBuf> },
|
||||
}
|
||||
|
||||
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
||||
@@ -2166,6 +2313,102 @@ mod tests {
|
||||
assert_eq!(req["worktree"]["create_branch"], json!(true));
|
||||
}
|
||||
|
||||
/// The command bar's `~` pins the spawn: the directory the operator named
|
||||
/// wins over the recency ranking that plain fast-forward would follow.
|
||||
#[tokio::test]
|
||||
async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() {
|
||||
use axum::routing::{get, post};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
async fn info() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
||||
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
||||
}))
|
||||
}
|
||||
async fn projects() -> Json<serde_json::Value> {
|
||||
Json(json!([
|
||||
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
|
||||
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"},
|
||||
{"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned",
|
||||
"last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(),
|
||||
"last_command": "zsh"}
|
||||
]))
|
||||
}
|
||||
async fn names() -> Json<serde_json::Value> {
|
||||
Json(json!({"names": ["crimson-otter"]}))
|
||||
}
|
||||
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
|
||||
let seen = posted.clone();
|
||||
async fn spawn_session(
|
||||
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
seen.lock().unwrap().push(body);
|
||||
Json(json!({
|
||||
"id": uuid::Uuid::from_u128(78).to_string(),
|
||||
"cwd": "/repos/pinned", "command": "zsh",
|
||||
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
|
||||
}))
|
||||
}
|
||||
|
||||
let app_router = axum::Router::new()
|
||||
.route(maestro_protocol::routes::INFO, get(info))
|
||||
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
||||
.route(maestro_protocol::routes::NAMES, get(names))
|
||||
.route(
|
||||
maestro_protocol::routes::SESSIONS,
|
||||
post(spawn_session).with_state(seen),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
||||
|
||||
let mut app = make_app_at(&base);
|
||||
app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))])
|
||||
.await;
|
||||
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
||||
.await
|
||||
.expect("load completed")
|
||||
.expect("channel open");
|
||||
app.apply_draft_update(update).await;
|
||||
|
||||
let posts = posted.lock().unwrap();
|
||||
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
|
||||
assert_eq!(posts[0]["cwd"], json!("/repos/pinned"));
|
||||
assert_eq!(posts[0]["command"], json!("zsh"));
|
||||
}
|
||||
|
||||
/// A directory the project scan does not know resolves no project, which
|
||||
/// is an unresolvable default: the form opens seeded with it rather than
|
||||
/// the spawn silently landing somewhere else.
|
||||
#[tokio::test]
|
||||
async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() {
|
||||
let mut app = make_app();
|
||||
app.fast_forward_spawn(
|
||||
None,
|
||||
Some(vec![maestro_protocol::Project {
|
||||
path: PathBuf::from("/repos/fresh"),
|
||||
name: "fresh".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
last_activity: Some(Utc::now()),
|
||||
last_command: Some("aider".into()),
|
||||
}]),
|
||||
vec!["crimson-otter".into()],
|
||||
None,
|
||||
Some(PathBuf::from("/elsewhere")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||
let draft = app.draft.as_ref().expect("form is up");
|
||||
// The seed is dropped by the picker when no project matches it, so
|
||||
// the operator picks by hand — but nothing spawned in `/repos/fresh`.
|
||||
assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh")));
|
||||
}
|
||||
|
||||
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
|
||||
/// switching which session the pane shows, and leaving the pane back to
|
||||
/// the sidebar. A sidebar highlight alone must report nothing.
|
||||
@@ -2716,6 +2959,284 @@ mod tests {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// -- command bar (#153) -------------------------------------------------
|
||||
|
||||
fn seed_two_sessions(app: &mut App) -> (Uuid, Uuid) {
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
|
||||
id: Uuid::from_u128(n),
|
||||
command: "bash".into(),
|
||||
cwd: std::path::PathBuf::from("/"),
|
||||
env: Default::default(),
|
||||
metadata: HashMap::from([(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
)]),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: focused,
|
||||
};
|
||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![
|
||||
make(1, "alpha", Some(older)),
|
||||
make(2, "bravo", Some(Utc::now())),
|
||||
],
|
||||
});
|
||||
(Uuid::from_u128(1), Uuid::from_u128(2))
|
||||
}
|
||||
|
||||
/// `ctrl-k` is an ordinary keymap entry, bound in both modes so the bar
|
||||
/// opens from the sidebar and from inside a pane (`docs/COMMAND-BAR.md`).
|
||||
#[test]
|
||||
fn ctrl_k_is_bound_to_the_command_bar_in_both_modes() {
|
||||
let app = make_app();
|
||||
assert_eq!(
|
||||
app.cfg.keymap.resolve(Mode::Normal, "ctrl-k"),
|
||||
Some(Action::OpenCommandBar)
|
||||
);
|
||||
assert_eq!(
|
||||
app.cfg.keymap.resolve(Mode::Insert, "ctrl-k"),
|
||||
Some(Action::OpenCommandBar)
|
||||
);
|
||||
}
|
||||
|
||||
/// The help overlay builds its lines from the live keymap, so the bar
|
||||
/// appears there with no second list to maintain.
|
||||
#[test]
|
||||
fn the_help_overlay_lists_the_command_bar() {
|
||||
let app = make_app();
|
||||
let lines = render_help_lines(&app.cfg);
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l.contains("ctrl-k") && l.contains("Go to a session")),
|
||||
"{lines:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_the_bar_lists_sessions_most_recently_focused_first() {
|
||||
let mut app = make_app();
|
||||
let (alpha, bravo) = seed_two_sessions(&mut app);
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
||||
let bar = app.command_bar.as_ref().unwrap();
|
||||
assert_eq!(bar.query(), "");
|
||||
assert_eq!(
|
||||
bar.rows()
|
||||
.iter()
|
||||
.map(|r| match r.target {
|
||||
BarTarget::Session(id) => id,
|
||||
ref other => panic!("expected a session row, got {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![bravo, alpha]
|
||||
);
|
||||
}
|
||||
|
||||
/// `~`'s candidates are a network round trip, so opening asks for them
|
||||
/// rather than blocking the keystroke on one.
|
||||
#[test]
|
||||
fn opening_the_bar_asks_for_its_directories() {
|
||||
let mut app = make_app();
|
||||
let effects = app.dispatch(Action::OpenCommandBar);
|
||||
let bar = app.command_bar.as_ref().unwrap().id();
|
||||
assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]);
|
||||
}
|
||||
|
||||
fn type_into_bar(app: &mut App, text: &str) {
|
||||
for c in text.chars() {
|
||||
app.handle_overlay_input(&InputKey::Char {
|
||||
c,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `>` lists the live keymap for the current mode, matched on the label
|
||||
/// alone, and Enter hands the action to the ordinary dispatcher.
|
||||
#[test]
|
||||
fn enter_on_a_command_row_dispatches_the_action() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
type_into_bar(&mut app, ">defaults");
|
||||
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
|
||||
assert_eq!(row.name, "Create session with defaults");
|
||||
assert_eq!(
|
||||
row.target,
|
||||
BarTarget::Command(Action::FastForwardSpawn),
|
||||
"chords: {:?}",
|
||||
row.chords
|
||||
);
|
||||
|
||||
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert_eq!(effects, vec![Effect::FastForward]);
|
||||
assert!(app.state.overlay().is_none());
|
||||
assert!(app.command_bar.is_none());
|
||||
}
|
||||
|
||||
/// The row shows the chord bound to it, and both chords when the map binds
|
||||
/// two — but the chord is never what matched.
|
||||
#[test]
|
||||
fn a_command_row_carries_its_chords() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
type_into_bar(&mut app, ">open selected");
|
||||
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
|
||||
assert_eq!(row.name, "Open selected item");
|
||||
assert!(
|
||||
row.chords.iter().any(|c| c == "enter"),
|
||||
"chords: {:?}",
|
||||
row.chords
|
||||
);
|
||||
assert!(
|
||||
row.chords.len() > 1,
|
||||
"the default map binds more than one chord to it: {:?}",
|
||||
row.chords
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_directory_row_fast_forward_spawns_there() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
app.command_bar
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_directories(&[PathBuf::from("/repos/api")]);
|
||||
type_into_bar(&mut app, "~api");
|
||||
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert_eq!(
|
||||
effects,
|
||||
vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))]
|
||||
);
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
|
||||
/// A load that outlived its bar fills in nothing: the reopened bar has its
|
||||
/// own identity.
|
||||
#[tokio::test]
|
||||
async fn a_stale_directory_load_is_dropped() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
let stale = app.command_bar.as_ref().unwrap().id();
|
||||
app.handle_overlay_input(&InputKey::Esc);
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
|
||||
app.apply_draft_update(DraftUpdate::BarDirectories {
|
||||
bar: stale,
|
||||
paths: vec![PathBuf::from("/repos/api")],
|
||||
})
|
||||
.await;
|
||||
type_into_bar(&mut app, "~");
|
||||
assert!(app.command_bar.as_ref().unwrap().rows().is_empty());
|
||||
}
|
||||
|
||||
/// Opening from Insert mode leaves the keyboard where it was, so closing
|
||||
/// gives it back — the bar is a view over the app, not a place in it.
|
||||
#[test]
|
||||
fn opening_from_insert_mode_leaves_focus_and_mode_alone() {
|
||||
let mut app = make_app();
|
||||
seed_two_sessions(&mut app);
|
||||
app.state.attach_to_focused(Uuid::from_u128(1));
|
||||
assert_eq!(
|
||||
(app.state.focus, app.state.mode),
|
||||
(Focus::Pane, Mode::Insert)
|
||||
);
|
||||
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
||||
assert_eq!(
|
||||
(app.state.focus, app.state.mode),
|
||||
(Focus::Pane, Mode::Insert)
|
||||
);
|
||||
|
||||
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert!(effects.is_empty());
|
||||
assert!(app.state.overlay().is_none());
|
||||
assert!(app.command_bar.is_none());
|
||||
assert_eq!(
|
||||
(app.state.focus, app.state.mode),
|
||||
(Focus::Pane, Mode::Insert)
|
||||
);
|
||||
}
|
||||
|
||||
/// Enter goes through the same open path the sidebar's attach uses.
|
||||
#[test]
|
||||
fn enter_attaches_the_highlighted_session() {
|
||||
let mut app = make_app();
|
||||
let (_alpha, bravo) = seed_two_sessions(&mut app);
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert_eq!(effects, vec![Effect::Attach(bravo)]);
|
||||
assert!(app.state.overlay().is_none());
|
||||
assert!(app.command_bar.is_none());
|
||||
assert_eq!(app.state.active_session(), Some(bravo));
|
||||
}
|
||||
|
||||
/// Down moves the highlight, so Enter opens the second row instead.
|
||||
#[test]
|
||||
fn the_highlight_moves_before_enter_attaches() {
|
||||
let mut app = make_app();
|
||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
app.handle_overlay_input(&InputKey::Down);
|
||||
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||
assert_eq!(effects, vec![Effect::Attach(alpha)]);
|
||||
assert_eq!(app.state.active_session(), Some(alpha));
|
||||
}
|
||||
|
||||
/// Typing goes to the query, not to the keymap: `q` must not quit and `x`
|
||||
/// must not raise a kill confirm while the bar is up.
|
||||
#[test]
|
||||
fn typing_reaches_the_query_rather_than_the_keymap() {
|
||||
let mut app = make_app();
|
||||
seed_two_sessions(&mut app);
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
for c in "bra".chars() {
|
||||
let (decision, _) = app.handle_overlay_input(&InputKey::Char {
|
||||
c,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
});
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
}
|
||||
let bar = app.command_bar.as_ref().unwrap();
|
||||
assert_eq!(bar.query(), "bra");
|
||||
assert_eq!(bar.rows().len(), 1);
|
||||
}
|
||||
|
||||
/// The bar is not previewed into (`docs/PREVIEW.md`): opening discards a
|
||||
/// preview and the highlight behind it raises no new one.
|
||||
#[test]
|
||||
fn opening_the_bar_discards_a_preview() {
|
||||
let mut app = make_app();
|
||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Session(alpha));
|
||||
app.state.preview = Some(alpha);
|
||||
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
assert_eq!(app.state.preview, None);
|
||||
assert_eq!(app.pending_preview, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_highlighted_opens_confirm() {
|
||||
use chrono::Utc;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("pageup", "ScrollPanePageUp"),
|
||||
("pagedown", "ScrollPanePageDown"),
|
||||
// Chrome
|
||||
// The command bar (#153). An ordinary entry, so it is rebindable and the
|
||||
// help overlay picks it up from the live keymap.
|
||||
("ctrl-k", "OpenCommandBar"),
|
||||
("ctrl-w b", "ToggleSidebar"),
|
||||
("?", "ShowHelp"),
|
||||
("ctrl-w q", "Quit"),
|
||||
@@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||
];
|
||||
|
||||
/// Default Insert-mode bindings — deliberately empty. Every key typed in
|
||||
/// Default Insert-mode bindings — almost empty on purpose. Every key typed in
|
||||
/// Insert mode belongs to the session, `esc` included: full-screen apps read
|
||||
/// it constantly, so binding it here would make it unreachable. The only way
|
||||
/// back to the sidebar is the leader (`LeaveInsert`), which
|
||||
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
|
||||
/// the Insert escape with it.
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
||||
///
|
||||
/// The one exception is the command bar (#153): `ctrl-k` opens it from inside
|
||||
/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's
|
||||
/// kill-to-end-of-line is given back through the leader (#156).
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")];
|
||||
|
||||
impl TuiConfig {
|
||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||
|
||||
@@ -49,6 +49,33 @@ pub fn resolve(
|
||||
.into_iter()
|
||||
.next()
|
||||
});
|
||||
defaults(project, presets, handle)
|
||||
}
|
||||
|
||||
/// Resolve the defaults for a spawn pinned to one directory — the command
|
||||
/// bar's `~` (#154), where the operator named the directory and nothing may
|
||||
/// override it. Unlike [`resolve`] there is no recency fallback: a directory
|
||||
/// the project scan does not know leaves `project` `None`, which is an
|
||||
/// unresolvable default, so the keystroke opens the pre-filled form instead of
|
||||
/// spawning somewhere else.
|
||||
pub fn resolve_at(
|
||||
dir: &std::path::Path,
|
||||
projects: &[Project],
|
||||
presets: &[SpawnPreset],
|
||||
handle: Option<String>,
|
||||
) -> FastForwardDefaults {
|
||||
let project = projects.iter().find(|p| p.path == dir).cloned();
|
||||
defaults(project, presets, handle)
|
||||
}
|
||||
|
||||
/// The project's last command, or the first non-custom preset, wrapped up with
|
||||
/// the rest of the defaults. Shared by both resolvers so the two entry points
|
||||
/// can never settle on different commands.
|
||||
fn defaults(
|
||||
project: Option<Project>,
|
||||
presets: &[SpawnPreset],
|
||||
handle: Option<String>,
|
||||
) -> FastForwardDefaults {
|
||||
let command = project
|
||||
.as_ref()
|
||||
.and_then(|p| p.last_command.clone())
|
||||
@@ -159,6 +186,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The command bar's `~` names the directory; nothing may override it.
|
||||
#[test]
|
||||
fn resolve_at_pins_the_directory_over_the_recency_ranking() {
|
||||
let projects = vec![
|
||||
project("/repos/fresh", 0, Some("aider")),
|
||||
project("/repos/pinned", 30, Some("zsh")),
|
||||
];
|
||||
let resolved = resolve_at(
|
||||
std::path::Path::new("/repos/pinned"),
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("otter".into()),
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.project.as_ref().map(|p| p.path.clone()),
|
||||
Some(PathBuf::from("/repos/pinned"))
|
||||
);
|
||||
assert_eq!(resolved.command.as_deref(), Some("zsh"));
|
||||
let req = resolved.build_request().expect("every default resolved");
|
||||
assert_eq!(req.cwd, PathBuf::from("/repos/pinned"));
|
||||
}
|
||||
|
||||
/// A directory the scan does not know resolves no project, so there is no
|
||||
/// request to build — the caller opens the pre-filled form instead of
|
||||
/// spawning somewhere else.
|
||||
#[test]
|
||||
fn resolve_at_takes_no_fallback_for_an_unknown_directory() {
|
||||
let projects = vec![project("/repos/fresh", 0, Some("aider"))];
|
||||
let resolved = resolve_at(
|
||||
std::path::Path::new("/elsewhere"),
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("otter".into()),
|
||||
);
|
||||
assert_eq!(resolved.project, None);
|
||||
// The preset still resolves; the missing project is what blocks it.
|
||||
assert_eq!(resolved.command.as_deref(), Some("claude"));
|
||||
assert_eq!(resolved.build_request(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_every_default_from_the_most_recent_project() {
|
||||
let projects = vec![
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod action;
|
||||
pub mod app;
|
||||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod command_bar;
|
||||
pub mod config;
|
||||
pub mod daemon_link;
|
||||
pub mod draft;
|
||||
|
||||
@@ -86,6 +86,9 @@ pub struct RenderInputs<'a> {
|
||||
pub help: &'a HelpOverlay,
|
||||
/// Edit buffer behind the rename prompt (#125).
|
||||
pub rename_input: &'a crate::text_input::TextInput,
|
||||
/// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is
|
||||
/// up.
|
||||
pub command_bar: Option<&'a crate::command_bar::CommandBar>,
|
||||
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||
/// restart overlay (`docs/RESTART.md`).
|
||||
pub link: &'a crate::daemon_link::LinkStatus,
|
||||
@@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
}
|
||||
map.modal = Some(area);
|
||||
}
|
||||
Some(OverlayKind::CommandBar) => {
|
||||
if let Some(bar) = inputs.command_bar {
|
||||
if let Some((x, y)) = bar.render(f, area) {
|
||||
f.set_cursor_position((x, y));
|
||||
}
|
||||
map.modal = Some(area);
|
||||
}
|
||||
}
|
||||
Some(OverlayKind::Help) => {
|
||||
inputs.help.render(f, area);
|
||||
map.modal = Some(area);
|
||||
@@ -428,6 +439,7 @@ mod tests {
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link,
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||
@@ -524,6 +536,7 @@ mod tests {
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
|
||||
@@ -578,6 +591,7 @@ mod tests {
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
@@ -619,6 +633,7 @@ mod tests {
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
|
||||
@@ -52,6 +52,10 @@ pub enum OverlayKind {
|
||||
/// so this stays comparable.
|
||||
Rename(Uuid),
|
||||
Help,
|
||||
/// The `ctrl-k` command bar (#153). Its query buffer and result list live
|
||||
/// on `App` as [`crate::command_bar::CommandBar`], for the same reason the
|
||||
/// rename buffer does — so this stays comparable.
|
||||
CommandBar,
|
||||
Toast(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface AttachConnection {
|
||||
onUnmount(): void;
|
||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||
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. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -506,6 +510,9 @@ export function createAttachConnection(
|
||||
focusTerminal() {
|
||||
term.focus();
|
||||
},
|
||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||
// bar already take.
|
||||
sendInput,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||
// kill or rename and raises the flow the card already has, so what the page
|
||||
// runs is what the ✕ button and the `r` key run.
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
});
|
||||
|
||||
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: { name: "amber-falcon" },
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function mount(s: Session): HTMLElement {
|
||||
const handle = renderSidebar(storeWith([s]));
|
||||
mounted = handle;
|
||||
document.body.appendChild(handle.el);
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function raise(name: string, s: Session): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("kill from the action page", () => {
|
||||
it("goes through the daemon, without a first confirmation", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const s = session();
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||
window.fetch = ((url: string) => {
|
||||
if (String(url).includes("force=true")) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename from the action page", () => {
|
||||
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||
folder?.click();
|
||||
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||
expect(input?.value).toBe("amber-falcon");
|
||||
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
abbreviateCwd,
|
||||
abbreviateFolderName,
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
sessionBlockers,
|
||||
isProvisionalName,
|
||||
sessionName,
|
||||
sessionTreePath,
|
||||
} from "./types.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import {
|
||||
@@ -50,6 +52,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||
* through the same confirm-and-blockers flow the card's ✕ does
|
||||
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||
|
||||
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||
|
||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||
* hash navigation, but a split/drag can move focus without ever touching
|
||||
* `location.hash` — so a click back onto a session whose hash still
|
||||
@@ -745,6 +758,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// The command bar's action page (#151) picks an action and hands it here:
|
||||
// both flows are the ones the card already has, reached by session rather
|
||||
// than by row.
|
||||
const killHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
void requestKill(s, sessionName(s));
|
||||
};
|
||||
const renameHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
const row = rowForSession(s);
|
||||
if (row) beginRename(row, s);
|
||||
};
|
||||
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
|
||||
/** The card a session is rendered as, expanding whatever folder hides it
|
||||
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||
* would otherwise swallow the request. */
|
||||
function rowForSession(s: Session): HTMLElement | null {
|
||||
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||
const found = find();
|
||||
if (found) return found;
|
||||
// Folder paths are the abbreviated directory's segments joined back up
|
||||
// (see buildTree), so the session's own path is normalised the same way
|
||||
// before it is compared against them.
|
||||
const cwd = sessionTreePath(s);
|
||||
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||
for (const folder of [...collapsed]) {
|
||||
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||
}
|
||||
rerenderHandler();
|
||||
return find();
|
||||
}
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||
|
||||
@@ -794,6 +841,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
|
||||
+420
-6
@@ -1,18 +1,30 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
closeCommandBar,
|
||||
commandBarCandidates,
|
||||
dedupeCommands,
|
||||
directoryBarCandidates,
|
||||
directoryCandidate,
|
||||
isCommandBarOpen,
|
||||
openCommandBar,
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionBarCandidates,
|
||||
sessionActionCandidates,
|
||||
sessionCandidate,
|
||||
SPAWN_DIRECTORY_EVENT,
|
||||
} from "./commandBar.js";
|
||||
import { COMMAND_SPECS } from "./commands.js";
|
||||
import { COMMAND_SPECS, type Command } from "./commands.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
@@ -173,8 +185,12 @@ describe("the overlay", () => {
|
||||
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) {
|
||||
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", () => {
|
||||
@@ -252,7 +268,7 @@ describe("the overlay", () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
openCommandBar({ sessions, homeDir: "/home/me" });
|
||||
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
@@ -262,7 +278,7 @@ describe("the overlay", () => {
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
@@ -301,9 +317,407 @@ describe("the overlay", () => {
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
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();
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page (#151)", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
];
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [],
|
||||
onOpenSession: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function headerText(): string | undefined {
|
||||
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
expect(activeName()).toBe("Kill session");
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
type("spwn");
|
||||
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||
type("zzz");
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||
onOpenSession: () => {},
|
||||
});
|
||||
type(">");
|
||||
const before = rowNames();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(before);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||
type("~");
|
||||
await Promise.resolve();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type("");
|
||||
press("Backspace");
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
press("Backspace");
|
||||
expect(headerText()).toContain("api");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
const seen: string[] = [];
|
||||
for (const [hook, key] of [
|
||||
["onKillSession", "Kill session"],
|
||||
["onRenameSession", "Rename session"],
|
||||
["onSpawnSimilar", "Spawn similar"],
|
||||
] as const) {
|
||||
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type(key);
|
||||
press("Enter");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
"onKillSession:worker",
|
||||
"onRenameSession:worker",
|
||||
"onSpawnSimilar:worker",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
const seen: string[] = [];
|
||||
const listeners: Array<[string, EventListener]> = [
|
||||
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
];
|
||||
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||
try {
|
||||
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||
open();
|
||||
press("Tab");
|
||||
type(action);
|
||||
press("Enter");
|
||||
}
|
||||
} finally {
|
||||
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||
}
|
||||
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||
});
|
||||
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+460
-32
@@ -1,33 +1,58 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Sessions are the only kind here (#149); the sigils, the action page and the
|
||||
// terminal-focus binding land in #150-#152.
|
||||
// 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 { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
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 "./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 which session 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`. */
|
||||
/** 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 };
|
||||
|
||||
export type BarCandidate = SessionBarCandidate;
|
||||
/** 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 {
|
||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: BarResult[];
|
||||
results: MatchResult<C>[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
@@ -36,6 +61,63 @@ const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
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 {
|
||||
@@ -99,14 +181,92 @@ export function sessionBarCandidates(
|
||||
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(results: readonly BarResult[]): BarGroup[] {
|
||||
const groups: BarGroup[] = [];
|
||||
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);
|
||||
@@ -122,7 +282,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
||||
/** 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(query: string, candidates: readonly BarCandidate[]): BarResult[] {
|
||||
export function barRows<C extends BarCandidate>(
|
||||
query: string,
|
||||
candidates: readonly C[],
|
||||
): MatchResult<C>[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
@@ -145,9 +308,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]):
|
||||
return frag;
|
||||
}
|
||||
|
||||
/** The secondary line under a row's name: the field the match was found in when
|
||||
* that was not the name, so a session found through its directory says so. */
|
||||
/** 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":
|
||||
@@ -162,22 +341,46 @@ function metaText(result: BarResult): string | undefined {
|
||||
}
|
||||
|
||||
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.sessionId = result.candidate.session.id;
|
||||
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
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 = result.candidate.fields.name;
|
||||
name.append(
|
||||
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
|
||||
);
|
||||
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");
|
||||
@@ -189,6 +392,12 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
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 {
|
||||
@@ -200,6 +409,25 @@ export interface CommandBarOptions {
|
||||
* 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 {
|
||||
@@ -228,8 +456,29 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
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 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");
|
||||
el.className = "cmdbar-overlay";
|
||||
@@ -246,10 +495,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
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.spellcheck = false;
|
||||
input.placeholder = "Go to a session…";
|
||||
input.placeholder = PLACEHOLDERS.mixed;
|
||||
|
||||
const results = document.createElement("div");
|
||||
results.className = "cmdbar-results";
|
||||
@@ -263,6 +512,98 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
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;
|
||||
@@ -276,8 +617,28 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
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 {
|
||||
rows = barRows(input.value, candidates);
|
||||
// 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;
|
||||
@@ -294,11 +655,15 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
// 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);
|
||||
@@ -317,19 +682,82 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
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 sessionId = result.candidate.session.id;
|
||||
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();
|
||||
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 {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
close();
|
||||
// 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();
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
DEFAULT_LEADER,
|
||||
canonicalizeKey,
|
||||
defineCommands,
|
||||
isBarTypingTarget,
|
||||
isTerminalTextarea,
|
||||
isTypingTarget,
|
||||
keyCaps,
|
||||
publishedCommands,
|
||||
@@ -234,3 +236,74 @@ describe("isTypingTarget", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* create: labels, chords and grouping live here and nowhere else. */
|
||||
export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
@@ -56,6 +60,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
keys: ["ctrl-k"],
|
||||
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",
|
||||
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
|
||||
* also where the leader has to work — a pane is exactly where the operator
|
||||
* 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;
|
||||
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 ----
|
||||
|
||||
const CAPS: Record<string, string> = {
|
||||
@@ -292,10 +311,6 @@ export function keyCaps(chord: string): string[] {
|
||||
|
||||
// ---- 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
|
||||
* `chord_timeout_ms` default. */
|
||||
export const DEFAULT_CHORD_TIMEOUT_MS = 1000;
|
||||
|
||||
@@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => {
|
||||
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();
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
close(id: Uuid): void {
|
||||
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 { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
|
||||
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js";
|
||||
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { installRestartOverlay } from "./restartOverlay.js";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
createSession,
|
||||
fetchNames,
|
||||
getDaemonInfo,
|
||||
gitRoot,
|
||||
projects as fetchProjects,
|
||||
} from "./api.js";
|
||||
import {
|
||||
@@ -337,6 +338,16 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void
|
||||
// a new branch named after that name. Anything unresolvable (no projects
|
||||
// known yet, no command, no generated name) opens the form pre-filled with
|
||||
// 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> {
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
@@ -344,13 +355,7 @@ async function requestFastForward(): Promise<void> {
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) presets = info.presets;
|
||||
} catch {
|
||||
/* keep fallbacks */
|
||||
}
|
||||
const presets = await spawnPresets();
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
const spawnKeys = new CommandSet(
|
||||
defineCommands({
|
||||
@@ -405,6 +451,11 @@ const focusKeys = new CommandSet(
|
||||
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 },
|
||||
);
|
||||
@@ -433,9 +484,10 @@ window.addEventListener(
|
||||
if (focusKeys.handle(e)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
// Opening the bar over a focused terminal is #152; until then the pane's
|
||||
// hidden textarea keeps its Ctrl+K, like any other typing target.
|
||||
if (!isTypingTarget(e.target) && barKeys.handle(e)) return;
|
||||
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
|
||||
// seen ahead of xterm and opens the bar (#152). A real form field — the
|
||||
// spawn form's inputs — keeps its own Ctrl+K.
|
||||
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
|
||||
spawnKeys.handle(e);
|
||||
},
|
||||
true,
|
||||
|
||||
@@ -1472,6 +1472,15 @@ input.leaf-rename:focus {
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
/* The action page's header (#151): names the session being acted on, so the
|
||||
target of kill/rename/spawn-similar is never ambiguous. */
|
||||
.cmdbar-page-header {
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cmdbar-group-title {
|
||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||
font-size: var(--fs-11);
|
||||
@@ -1517,6 +1526,25 @@ input.leaf-rename:focus {
|
||||
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 {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user