feat(maestro): add the TUI command bar's > and ~ sigils
`>` narrows to commands from the live keymap for the current mode, matched on the action's help_label only — the chord is displayed, never matched, so results cannot depend on a user's rebinds. Rows dedupe by action, not by chord: the default map binds both `enter` and `tab` to AttachHighlighted, and AttachHighlighted and ToggleAttachHighlighted share a label because they are the same match arm. `~` narrows to recent dirs plus the project list. Enter takes the fast-forward path pinned to that directory, with no recency fallback, so a directory outside the project scan opens the pre-filled form rather than spawning elsewhere. Both fall out of parsing the query line, as in web/src/commandBar.ts: a sigil is only a sigil in first position, and backspacing past one is just the query becoming mixed again.
This commit is contained in:
+306
-16
@@ -168,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 {
|
||||
@@ -386,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,
|
||||
@@ -459,6 +469,16 @@ impl App {
|
||||
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) => {
|
||||
@@ -506,7 +526,7 @@ impl App {
|
||||
};
|
||||
self.state.show_overlay(OverlayKind::Help);
|
||||
}
|
||||
Action::OpenCommandBar => self.open_command_bar(),
|
||||
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
||||
Action::OpenSpawnModal => {
|
||||
self.open_spawn_modal();
|
||||
effects.push(Effect::LoadDraft);
|
||||
@@ -628,12 +648,21 @@ impl App {
|
||||
/// 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) {
|
||||
fn open_command_bar(&mut self) -> Vec<Effect> {
|
||||
let snapshot = self.catalog.snapshot();
|
||||
self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir));
|
||||
// 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
|
||||
@@ -990,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.
|
||||
@@ -1036,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();
|
||||
@@ -1064,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
|
||||
@@ -1087,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;
|
||||
@@ -1105,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);
|
||||
}
|
||||
@@ -1463,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`.
|
||||
@@ -2222,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.
|
||||
@@ -2850,12 +3037,115 @@ mod tests {
|
||||
.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]
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
//! The `ctrl-k` command bar (`docs/COMMAND-BAR.md`): a query line over a
|
||||
//! result list, opened from Normal or Insert mode, closed with Escape.
|
||||
//!
|
||||
//! Sessions are the only kind here (#153); the sigils (#154), the session
|
||||
//! action page (#155) and the leader's literal Ctrl-K (#156) land later. The
|
||||
//! grouping below is already kind-generic so those slot in without a rewrite.
|
||||
//! Three kinds live here: sessions and commands, ranked together by default,
|
||||
//! and directories, which need the `~` sigil (#154). The session action page
|
||||
//! (#155) and the leader's literal Ctrl-K (#156) land later.
|
||||
//!
|
||||
//! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the
|
||||
//! groups are a rendering of the ranked list, in the order their best-scoring
|
||||
//! member appears. The web bar (`web/src/commandBar.ts`) implements the same
|
||||
//! contract; the two must agree.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use maestro_protocol::display::{abbreviate_dir, command_tool, session_tree_path};
|
||||
use maestro_protocol::{Session, SessionStatus};
|
||||
use ratatui::layout::Rect;
|
||||
@@ -19,19 +21,73 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::input::InputKey;
|
||||
use crate::matcher::{
|
||||
match_and_rank, CandidateKind, MatchCandidate, MatchFieldName, MatchRange, SessionCandidate,
|
||||
match_and_rank, CandidateKind, CommandCandidate, DirectoryCandidate, MatchCandidate,
|
||||
MatchFieldName, MatchRange, SessionCandidate,
|
||||
};
|
||||
use crate::overlays::centered;
|
||||
use crate::palette;
|
||||
use crate::text_input::TextInput;
|
||||
|
||||
/// What Enter on a row does. Only sessions exist today; `>` commands and `~`
|
||||
/// directories (#154) become further variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Identity for routing an async directory load back to *this* bar: a load in
|
||||
/// flight must not land in a bar the operator closed and reopened.
|
||||
static NEXT_BAR_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
/// What Enter on a row does.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BarTarget {
|
||||
Session(Uuid),
|
||||
/// Dispatch the action the row stands for. The chord is displayed beside
|
||||
/// the row but is not part of the target: two chords bound to one action
|
||||
/// are one row.
|
||||
Command(Action),
|
||||
/// Fast-forward spawn in this directory, absolute — the abbreviated form
|
||||
/// is what the row shows and what the matcher ranked.
|
||||
Directory(PathBuf),
|
||||
}
|
||||
|
||||
/// The first character of the query, when it is one of the two that narrow the
|
||||
/// list to a single kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BarSigil {
|
||||
/// `>` — commands from the live keymap.
|
||||
Command,
|
||||
/// `~` — recent dirs and the project list.
|
||||
Directory,
|
||||
}
|
||||
|
||||
/// A raw query line split into its sigil and the query proper.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BarQuery<'q> {
|
||||
pub sigil: Option<BarSigil>,
|
||||
/// What the matcher sees: everything after a sigil, or the whole query.
|
||||
pub query: &'q str,
|
||||
}
|
||||
|
||||
/// 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. Mirrors the
|
||||
/// web's `parseBarQuery`.
|
||||
pub fn parse_bar_query(raw: &str) -> BarQuery<'_> {
|
||||
let sigil = match raw.chars().next() {
|
||||
Some('>') => BarSigil::Command,
|
||||
Some('~') => BarSigil::Directory,
|
||||
_ => {
|
||||
return BarQuery {
|
||||
sigil: None,
|
||||
query: raw,
|
||||
}
|
||||
}
|
||||
};
|
||||
BarQuery {
|
||||
sigil: Some(sigil),
|
||||
query: &raw[1..],
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the result list, already resolved to what it renders and what it
|
||||
@@ -50,6 +106,9 @@ pub struct BarRow {
|
||||
pub field: Option<MatchFieldName>,
|
||||
pub ranges: Vec<MatchRange>,
|
||||
pub dead: bool,
|
||||
/// Every chord bound to a command row's action, displayed beside it and
|
||||
/// never matched. Empty for every other kind.
|
||||
pub chords: Vec<String>,
|
||||
}
|
||||
|
||||
impl BarRow {
|
||||
@@ -78,7 +137,7 @@ fn kind_title(kind: CandidateKind) -> &'static str {
|
||||
}
|
||||
|
||||
/// What a keystroke asked the bar to do.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BarOutcome {
|
||||
/// The bar stays up.
|
||||
Stay,
|
||||
@@ -128,40 +187,112 @@ pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate
|
||||
}
|
||||
}
|
||||
|
||||
/// One command row: a distinct [`Action`] and every chord bound to it.
|
||||
///
|
||||
/// Dedupe is by **action, never by chord** — the default map binds both
|
||||
/// `enter` and `tab` to `AttachHighlighted`, and listed naively that is two
|
||||
/// identical rows. Actions that share a `help_label` collapse too:
|
||||
/// `AttachHighlighted` and `ToggleAttachHighlighted` are the same match arm in
|
||||
/// `app.rs` and genuinely do the same thing, so they are one row carrying both
|
||||
/// chords rather than two rows a reader cannot tell apart. `Noop` is left out:
|
||||
/// it exists to suppress a default binding, not as something to run.
|
||||
///
|
||||
/// Input order is the keymap's own (`Keymap::entries` sorts by chord), and the
|
||||
/// first mention of each action keeps its place — the registry order the
|
||||
/// contract asks an empty `>` query to list.
|
||||
pub fn command_entries(entries: &[(String, Action)]) -> Vec<(Action, Vec<String>)> {
|
||||
let mut out: Vec<(Action, Vec<String>)> = Vec::new();
|
||||
for (chord, action) in entries {
|
||||
if *action == Action::Noop {
|
||||
continue;
|
||||
}
|
||||
let label = action.help_label();
|
||||
match out
|
||||
.iter_mut()
|
||||
.find(|(kept, _)| kept == action || kept.help_label() == label)
|
||||
{
|
||||
Some((_, chords)) => {
|
||||
if !chords.iter().any(|c| c == chord) {
|
||||
chords.push(chord.clone());
|
||||
}
|
||||
}
|
||||
None => out.push((action.clone(), vec![chord.clone()])),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One candidate plus what it renders and what Enter does with it. The matcher
|
||||
/// never sees anything but `candidate`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct BarEntry {
|
||||
candidate: MatchCandidate,
|
||||
target: BarTarget,
|
||||
dead: bool,
|
||||
chords: Vec<String>,
|
||||
}
|
||||
|
||||
impl BarEntry {
|
||||
fn candidate_kind(&self) -> CandidateKind {
|
||||
match self.candidate {
|
||||
MatchCandidate::Session(_) => CandidateKind::Session,
|
||||
MatchCandidate::Command(_) => CandidateKind::Command,
|
||||
MatchCandidate::Directory(_) => CandidateKind::Directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's state while it is up: the query buffer, the candidates it was
|
||||
/// opened over, the rows the current query produces, and the highlight.
|
||||
#[derive(Debug)]
|
||||
pub struct CommandBar {
|
||||
/// Identity for routing an async directory load back to this bar.
|
||||
id: u64,
|
||||
input: TextInput,
|
||||
/// Matcher input, in the empty-query order. Never reordered — the matcher's
|
||||
/// last tie-break is what keeps that order meaningful.
|
||||
candidates: Vec<MatchCandidate>,
|
||||
/// What each candidate opens, parallel to `candidates`.
|
||||
targets: Vec<BarTarget>,
|
||||
/// Whether each candidate's session is dead, parallel to `candidates`.
|
||||
dead: Vec<bool>,
|
||||
home_dir: String,
|
||||
/// Matcher input per kind, in the empty-query order. Never reordered —
|
||||
/// the matcher's last tie-break is what keeps that order meaningful.
|
||||
sessions: Vec<BarEntry>,
|
||||
commands: Vec<BarEntry>,
|
||||
directories: Vec<BarEntry>,
|
||||
rows: Vec<BarRow>,
|
||||
highlight: usize,
|
||||
}
|
||||
|
||||
impl CommandBar {
|
||||
/// Open the bar over the sessions the catalog has right now.
|
||||
pub fn new(sessions: &[Session], home_dir: &str) -> Self {
|
||||
/// Open the bar over the sessions the catalog has right now and the live
|
||||
/// keymap for the current mode. Directories arrive later
|
||||
/// ([`Self::set_directories`]) — they are a network round trip and the
|
||||
/// keystroke must not wait on one.
|
||||
pub fn new(sessions: &[Session], keymap: &[(String, Action)], home_dir: &str) -> Self {
|
||||
let ordered = order_sessions_for_bar(sessions);
|
||||
let candidates = ordered
|
||||
let sessions = ordered
|
||||
.iter()
|
||||
.map(|s| MatchCandidate::Session(session_candidate(s, home_dir)))
|
||||
.map(|s| BarEntry {
|
||||
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
|
||||
target: BarTarget::Session(s.id),
|
||||
dead: s.status == SessionStatus::Dead,
|
||||
chords: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let targets = ordered.iter().map(|s| BarTarget::Session(s.id)).collect();
|
||||
let dead = ordered
|
||||
.iter()
|
||||
.map(|s| s.status == SessionStatus::Dead)
|
||||
let commands = command_entries(keymap)
|
||||
.into_iter()
|
||||
.map(|(action, chords)| BarEntry {
|
||||
candidate: MatchCandidate::Command(CommandCandidate {
|
||||
label: action.help_label(),
|
||||
}),
|
||||
target: BarTarget::Command(action),
|
||||
dead: false,
|
||||
chords,
|
||||
})
|
||||
.collect();
|
||||
let mut bar = Self {
|
||||
id: NEXT_BAR_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
input: TextInput::default(),
|
||||
candidates,
|
||||
targets,
|
||||
dead,
|
||||
home_dir: home_dir.to_string(),
|
||||
sessions,
|
||||
commands,
|
||||
directories: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
highlight: 0,
|
||||
};
|
||||
@@ -169,6 +300,34 @@ impl CommandBar {
|
||||
bar
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Hand the bar `~`'s candidates once the daemon has answered: recent dirs
|
||||
/// (newest first) ahead of the project list, deduped by the directory a
|
||||
/// spawn would land in, keeping the first mention of each.
|
||||
pub fn set_directories(&mut self, paths: &[PathBuf]) {
|
||||
let mut seen: Vec<&Path> = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
for path in paths {
|
||||
if path.as_os_str().is_empty() || seen.contains(&path.as_path()) {
|
||||
continue;
|
||||
}
|
||||
seen.push(path.as_path());
|
||||
entries.push(BarEntry {
|
||||
candidate: MatchCandidate::Directory(DirectoryCandidate {
|
||||
path: abbreviate_dir(path, &self.home_dir),
|
||||
}),
|
||||
target: BarTarget::Directory(path.clone()),
|
||||
dead: false,
|
||||
chords: Vec::new(),
|
||||
});
|
||||
}
|
||||
self.directories = entries;
|
||||
self.recompute();
|
||||
}
|
||||
|
||||
pub fn query(&self) -> &str {
|
||||
self.input.value()
|
||||
}
|
||||
@@ -224,7 +383,7 @@ impl CommandBar {
|
||||
match key {
|
||||
InputKey::Esc => BarOutcome::Close,
|
||||
InputKey::Enter => match self.selected() {
|
||||
Some(row) => BarOutcome::Open(row.target),
|
||||
Some(row) => BarOutcome::Open(row.target.clone()),
|
||||
// Nothing to open: the bar stays up rather than closing on a
|
||||
// key that did nothing.
|
||||
None => BarOutcome::Stay,
|
||||
@@ -257,31 +416,58 @@ impl CommandBar {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sigil in force for the query as it stands.
|
||||
pub fn sigil(&self) -> Option<BarSigil> {
|
||||
parse_bar_query(self.input.value()).sigil
|
||||
}
|
||||
|
||||
/// The candidates the query line asks for. A sigil narrows to one kind;
|
||||
/// with no sigil the list is mixed — sessions and commands ranked together
|
||||
/// — except on an empty query, where the bar is a session switcher.
|
||||
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
|
||||
match parsed.sigil {
|
||||
Some(BarSigil::Command) => self.commands.iter().collect(),
|
||||
Some(BarSigil::Directory) => self.directories.iter().collect(),
|
||||
None if parsed.query.is_empty() => self.sessions.iter().collect(),
|
||||
None => self.sessions.iter().chain(self.commands.iter()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-rank for the current query. The highlight starts on the globally
|
||||
/// best row every time the list changes: an old index would point at a
|
||||
/// different result.
|
||||
fn recompute(&mut self) {
|
||||
let rows: Vec<BarRow> = {
|
||||
let results = match_and_rank(self.input.value(), &self.candidates);
|
||||
results
|
||||
let parsed = parse_bar_query(self.input.value());
|
||||
let entries = self.active_entries(parsed);
|
||||
let candidates: Vec<MatchCandidate> =
|
||||
entries.iter().map(|e| e.candidate.clone()).collect();
|
||||
match_and_rank(parsed.query, &candidates)
|
||||
.iter()
|
||||
.map(|result| {
|
||||
let index = self
|
||||
.candidates
|
||||
let index = candidates
|
||||
.iter()
|
||||
.position(|c| std::ptr::eq(c, result.candidate))
|
||||
.expect("every result borrows one of this bar's candidates");
|
||||
let MatchCandidate::Session(session) = result.candidate else {
|
||||
unreachable!("sessions are the only kind the bar builds (#153)")
|
||||
.expect("every result borrows one of this query's candidates");
|
||||
let entry = entries[index];
|
||||
let (name, meta) = match &entry.candidate {
|
||||
MatchCandidate::Session(session) => {
|
||||
(session.name.clone(), meta_text(session, result.field))
|
||||
}
|
||||
// A command's label and a directory's path are the row
|
||||
// itself; neither has a second field to fall back to.
|
||||
MatchCandidate::Command(command) => (command.label.clone(), None),
|
||||
MatchCandidate::Directory(directory) => (directory.path.clone(), None),
|
||||
};
|
||||
BarRow {
|
||||
kind: CandidateKind::Session,
|
||||
target: self.targets[index],
|
||||
name: session.name.clone(),
|
||||
meta: meta_text(session, result.field),
|
||||
kind: entry.candidate_kind(),
|
||||
target: entry.target.clone(),
|
||||
name,
|
||||
meta,
|
||||
field: result.field,
|
||||
ranges: result.ranges.clone(),
|
||||
dead: self.dead[index],
|
||||
dead: entry.dead,
|
||||
chords: entry.chords.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -297,7 +483,11 @@ impl CommandBar {
|
||||
let area = centered(parent, 72, height);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.title(" go to ")
|
||||
.title(match self.sigil() {
|
||||
Some(BarSigil::Command) => " run a command ",
|
||||
Some(BarSigil::Directory) => " spawn in ",
|
||||
None => " go to ",
|
||||
})
|
||||
.borders(Borders::ALL)
|
||||
.style(pal.card.base())
|
||||
.border_style(pal.border_style(true));
|
||||
@@ -349,11 +539,16 @@ impl CommandBar {
|
||||
// sits on so the scroll can keep it visible.
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut highlight_line = 0usize;
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped — it would name what the whole list already is.
|
||||
let headers = self.sigil().is_none();
|
||||
for group in self.groups() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
group.title.to_string(),
|
||||
pal.group_row_style(),
|
||||
)));
|
||||
if headers {
|
||||
lines.push(Line::from(Span::styled(
|
||||
group.title.to_string(),
|
||||
pal.group_row_style(),
|
||||
)));
|
||||
}
|
||||
for index in group.start..group.start + group.len {
|
||||
if index == self.highlight {
|
||||
highlight_line = lines.len();
|
||||
@@ -384,6 +579,12 @@ impl CommandBar {
|
||||
&row.ranges
|
||||
};
|
||||
spans.extend(highlighted_spans(&row.name, name_ranges, base, hit));
|
||||
// The chord is teaching material, not a match target: it comes from
|
||||
// the live keymap and is never fed to the matcher.
|
||||
if !row.chords.is_empty() {
|
||||
spans.push(Span::styled(" ", base));
|
||||
spans.push(Span::styled(row.chords.join(", "), surface.muted_style()));
|
||||
}
|
||||
if let Some(meta) = &row.meta {
|
||||
let meta_style = surface.muted_style();
|
||||
let meta_hit = meta_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
|
||||
@@ -485,6 +686,7 @@ mod tests {
|
||||
rows.iter()
|
||||
.map(|r| match r.target {
|
||||
BarTarget::Session(id) => id,
|
||||
ref other => panic!("expected a session row, got {other:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -509,7 +711,7 @@ mod tests {
|
||||
let mut c = named(3, "charlie");
|
||||
c.last_focused_at = Some(at(20));
|
||||
|
||||
let bar = CommandBar::new(&[a, b, c], "/home/me");
|
||||
let bar = CommandBar::new(&[a, b, c], &[], "/home/me");
|
||||
assert_eq!(
|
||||
ids(bar.rows()),
|
||||
vec![Uuid::from_u128(2), Uuid::from_u128(3), Uuid::from_u128(1)]
|
||||
@@ -527,7 +729,7 @@ mod tests {
|
||||
let mut newer = named(3, "newer");
|
||||
newer.created_at = at(20);
|
||||
|
||||
let bar = CommandBar::new(&[older, newer, focused], "/home/me");
|
||||
let bar = CommandBar::new(&[older, newer, focused], &[], "/home/me");
|
||||
assert_eq!(
|
||||
ids(bar.rows()),
|
||||
vec![Uuid::from_u128(1), Uuid::from_u128(3), Uuid::from_u128(2)]
|
||||
@@ -542,7 +744,7 @@ mod tests {
|
||||
dead.last_focused_at = Some(at(99));
|
||||
let live = named(2, "live");
|
||||
|
||||
let bar = CommandBar::new(&[dead, live], "/home/me");
|
||||
let bar = CommandBar::new(&[dead, live], &[], "/home/me");
|
||||
assert_eq!(
|
||||
ids(bar.rows()),
|
||||
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
|
||||
@@ -552,7 +754,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
||||
let mut bar = CommandBar::new(&[named(1, "web-search"), named(2, "websearch")], "/home/me");
|
||||
let mut bar = CommandBar::new(
|
||||
&[named(1, "web-search"), named(2, "websearch")],
|
||||
&[],
|
||||
"/home/me",
|
||||
);
|
||||
typed(&mut bar, "ws");
|
||||
// F9: the `s` after `-` earns the boundary bonus.
|
||||
assert_eq!(
|
||||
@@ -565,7 +771,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_query_matching_nothing_leaves_no_rows() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
typed(&mut bar, "zzz");
|
||||
assert!(bar.rows().is_empty());
|
||||
assert!(bar.selected().is_none());
|
||||
@@ -577,7 +783,7 @@ mod tests {
|
||||
fn a_directory_match_highlights_in_the_meta_text() {
|
||||
let mut worker = named(1, "worker");
|
||||
worker.cwd = PathBuf::from("/home/me/dev/api");
|
||||
let mut bar = CommandBar::new(&[worker], "/home/me");
|
||||
let mut bar = CommandBar::new(&[worker], &[], "/home/me");
|
||||
typed(&mut bar, "api");
|
||||
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory));
|
||||
assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api"));
|
||||
@@ -590,7 +796,7 @@ mod tests {
|
||||
fn the_tool_field_is_a_candidate_field() {
|
||||
let mut nameless = named(1, "nameless");
|
||||
nameless.command = "claude --resume".into();
|
||||
let mut bar = CommandBar::new(&[nameless, named(2, "clay")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[nameless, named(2, "clay")], &[], "/home/me");
|
||||
typed(&mut bar, "claude");
|
||||
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]);
|
||||
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool));
|
||||
@@ -598,7 +804,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn results_group_under_kind_headers_with_the_best_row_first() {
|
||||
let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me");
|
||||
let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
|
||||
let groups = bar.groups();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].title, "Sessions");
|
||||
@@ -612,6 +818,7 @@ mod tests {
|
||||
fn arrows_and_ctrl_pn_move_the_highlight_without_wrapping() {
|
||||
let mut bar = CommandBar::new(
|
||||
&[named(1, "alpha"), named(2, "bravo"), named(3, "charlie")],
|
||||
&[],
|
||||
"/home/me",
|
||||
);
|
||||
assert_eq!(bar.handle_key(&InputKey::Down), BarOutcome::Stay);
|
||||
@@ -640,7 +847,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn enter_opens_the_highlighted_session_and_escape_closes() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Down);
|
||||
assert_eq!(
|
||||
bar.handle_key(&InputKey::Enter),
|
||||
@@ -651,14 +858,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn enter_with_no_results_leaves_the_bar_up() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
typed(&mut bar, "zzz");
|
||||
assert_eq!(bar.handle_key(&InputKey::Enter), BarOutcome::Stay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_query_re_ranks_and_resets_the_highlight() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Down);
|
||||
assert_eq!(bar.highlight(), 1);
|
||||
typed(&mut bar, "alp");
|
||||
@@ -676,7 +883,7 @@ mod tests {
|
||||
let mut recent = named(1, "alpha");
|
||||
recent.last_focused_at = Some(at(50));
|
||||
let older = named(2, "alpine");
|
||||
let mut bar = CommandBar::new(&[older, recent], "/home/me");
|
||||
let mut bar = CommandBar::new(&[older, recent], &[], "/home/me");
|
||||
typed(&mut bar, "alpi");
|
||||
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]);
|
||||
for _ in 0..4 {
|
||||
@@ -689,6 +896,251 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn keymap(pairs: &[(&str, Action)]) -> Vec<(String, Action)> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(chord, action)| (chord.to_string(), action.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn labels(rows: &[BarRow]) -> Vec<String> {
|
||||
rows.iter().map(|r| r.name.clone()).collect()
|
||||
}
|
||||
|
||||
// ---- sigil parsing ----
|
||||
|
||||
#[test]
|
||||
fn a_leading_sigil_is_a_sigil_and_the_rest_is_the_query() {
|
||||
assert_eq!(
|
||||
parse_bar_query(">kill"),
|
||||
BarQuery {
|
||||
sigil: Some(BarSigil::Command),
|
||||
query: "kill"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_bar_query("~dev"),
|
||||
BarQuery {
|
||||
sigil: Some(BarSigil::Directory),
|
||||
query: "dev"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_bar_query("api"),
|
||||
BarQuery {
|
||||
sigil: None,
|
||||
query: "api"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_bar_query(""),
|
||||
BarQuery {
|
||||
sigil: None,
|
||||
query: ""
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A directory query is where a second `~` is most likely to be typed, and
|
||||
/// it must reach the matcher.
|
||||
#[test]
|
||||
fn a_sigil_anywhere_but_first_is_a_literal_query_character() {
|
||||
assert_eq!(
|
||||
parse_bar_query("~~/dev"),
|
||||
BarQuery {
|
||||
sigil: Some(BarSigil::Directory),
|
||||
query: "~/dev"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_bar_query("a>b"),
|
||||
BarQuery {
|
||||
sigil: None,
|
||||
query: "a>b"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---- `>` commands ----
|
||||
|
||||
/// The default map binds both `enter` and `tab` to `AttachHighlighted`;
|
||||
/// listed naively that is two identical rows.
|
||||
#[test]
|
||||
fn command_rows_dedupe_by_action_and_keep_every_chord() {
|
||||
let entries = command_entries(&keymap(&[
|
||||
("enter", Action::AttachHighlighted),
|
||||
("tab", Action::AttachHighlighted),
|
||||
("q", Action::Quit),
|
||||
]));
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
(
|
||||
Action::AttachHighlighted,
|
||||
vec!["enter".to_string(), "tab".to_string()]
|
||||
),
|
||||
(Action::Quit, vec!["q".to_string()]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `AttachHighlighted` and `ToggleAttachHighlighted` share a `help_label`
|
||||
/// because they are the same match arm and genuinely do the same thing.
|
||||
/// Two rows a reader cannot tell apart is the bug; relabelling them would
|
||||
/// describe behaviour that does not exist.
|
||||
#[test]
|
||||
fn actions_sharing_a_label_collapse_into_one_row() {
|
||||
let entries = command_entries(&keymap(&[
|
||||
("enter", Action::AttachHighlighted),
|
||||
("o", Action::ToggleAttachHighlighted),
|
||||
]));
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].1, vec!["enter".to_string(), "o".to_string()]);
|
||||
}
|
||||
|
||||
/// `Noop` exists to suppress a default binding, not as something to run.
|
||||
#[test]
|
||||
fn noop_bindings_are_not_commands() {
|
||||
let entries = command_entries(&keymap(&[("x", Action::Noop), ("q", Action::Quit)]));
|
||||
assert_eq!(entries, vec![(Action::Quit, vec!["q".to_string()])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_sigil_narrows_to_commands_matched_on_their_label() {
|
||||
let map = keymap(&[
|
||||
("K", Action::KillHighlighted),
|
||||
("q", Action::Quit),
|
||||
("r", Action::RenameHighlighted),
|
||||
]);
|
||||
let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me");
|
||||
typed(&mut bar, ">kill");
|
||||
assert_eq!(
|
||||
labels(bar.rows()),
|
||||
vec!["Kill selected session".to_string()]
|
||||
);
|
||||
assert!(bar.rows().iter().all(|r| r.kind == CandidateKind::Command));
|
||||
assert_eq!(bar.rows()[0].chords, vec!["K".to_string()]);
|
||||
}
|
||||
|
||||
/// Matching the chord would make `>` results depend on a user's rebinds.
|
||||
#[test]
|
||||
fn a_chord_is_displayed_and_never_matched() {
|
||||
let map = keymap(&[("zzz", Action::Quit)]);
|
||||
let mut bar = CommandBar::new(&[], &map, "/home/me");
|
||||
typed(&mut bar, ">zzz");
|
||||
assert!(bar.rows().is_empty(), "{:?}", labels(bar.rows()));
|
||||
bar.handle_key(&InputKey::Backspace);
|
||||
bar.handle_key(&InputKey::Backspace);
|
||||
bar.handle_key(&InputKey::Backspace);
|
||||
assert_eq!(labels(bar.rows()), vec!["Quit Maestro".to_string()]);
|
||||
assert_eq!(bar.rows()[0].chords, vec!["zzz".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_command_row_dispatches_its_action() {
|
||||
let map = keymap(&[("q", Action::Quit)]);
|
||||
let mut bar = CommandBar::new(&[], &map, "/home/me");
|
||||
typed(&mut bar, ">quit");
|
||||
assert_eq!(
|
||||
bar.handle_key(&InputKey::Enter),
|
||||
BarOutcome::Open(BarTarget::Command(Action::Quit))
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty `>` query lists every command in keymap order.
|
||||
#[test]
|
||||
fn an_empty_command_query_lists_the_whole_keymap() {
|
||||
let map = keymap(&[("K", Action::KillHighlighted), ("q", Action::Quit)]);
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
|
||||
typed(&mut bar, ">");
|
||||
assert_eq!(
|
||||
labels(bar.rows()),
|
||||
vec![
|
||||
"Kill selected session".to_string(),
|
||||
"Quit Maestro".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- `~` directories ----
|
||||
|
||||
#[test]
|
||||
fn the_directory_sigil_narrows_to_directories_deduped_and_abbreviated() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
bar.set_directories(&[
|
||||
PathBuf::from("/home/me/dev/api"),
|
||||
PathBuf::from("/home/me/dev/api"),
|
||||
PathBuf::from("/srv/www"),
|
||||
]);
|
||||
typed(&mut bar, "~");
|
||||
assert_eq!(
|
||||
labels(bar.rows()),
|
||||
vec!["~/dev/api".to_string(), "/srv/www".to_string()]
|
||||
);
|
||||
assert!(bar
|
||||
.rows()
|
||||
.iter()
|
||||
.all(|r| r.kind == CandidateKind::Directory));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_directory_row_spawns_there() {
|
||||
let mut bar = CommandBar::new(&[], &[], "/home/me");
|
||||
bar.set_directories(&[PathBuf::from("/home/me/dev/api")]);
|
||||
typed(&mut bar, "~api");
|
||||
assert_eq!(
|
||||
bar.handle_key(&InputKey::Enter),
|
||||
BarOutcome::Open(BarTarget::Directory(PathBuf::from("/home/me/dev/api")))
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the mixed list ----
|
||||
|
||||
#[test]
|
||||
fn no_sigil_ranks_sessions_and_commands_together_under_kind_headers() {
|
||||
let map = keymap(&[("K", Action::KillHighlighted)]);
|
||||
let mut bar = CommandBar::new(&[named(1, "killer")], &map, "/home/me");
|
||||
typed(&mut bar, "kill");
|
||||
let groups = bar.groups();
|
||||
assert_eq!(
|
||||
groups.iter().map(|g| g.title).collect::<Vec<_>>(),
|
||||
vec!["Sessions", "Commands"]
|
||||
);
|
||||
assert_eq!(
|
||||
labels(bar.rows()),
|
||||
vec!["killer".to_string(), "Kill selected session".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty bar is a session switcher: commands and directories need a
|
||||
/// sigil or a query to appear.
|
||||
#[test]
|
||||
fn an_empty_query_lists_sessions_only() {
|
||||
let map = keymap(&[("q", Action::Quit)]);
|
||||
let bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
|
||||
assert_eq!(labels(bar.rows()), vec!["alpha".to_string()]);
|
||||
}
|
||||
|
||||
/// The sigil is one character of the query like any other: deleting it is
|
||||
/// not a special key.
|
||||
#[test]
|
||||
fn backspacing_past_a_sigil_returns_to_the_mixed_list() {
|
||||
let map = keymap(&[("K", Action::KillHighlighted)]);
|
||||
let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me");
|
||||
typed(&mut bar, ">kill");
|
||||
assert_eq!(bar.rows().len(), 1);
|
||||
assert_eq!(bar.sigil(), Some(BarSigil::Command));
|
||||
bar.handle_key(&InputKey::Home);
|
||||
bar.handle_key(&InputKey::Right);
|
||||
bar.handle_key(&InputKey::Backspace);
|
||||
assert_eq!(bar.query(), "kill");
|
||||
assert_eq!(bar.sigil(), None);
|
||||
assert_eq!(
|
||||
labels(bar.rows()),
|
||||
vec!["kill-me".to_string(), "Kill selected session".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
fn render_to_string(bar: &CommandBar, w: u16, h: u16) -> String {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
@@ -710,7 +1162,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn renders_the_query_the_headers_and_the_rows() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me");
|
||||
typed(&mut bar, "alp");
|
||||
let rendered = render_to_string(&bar, 90, 20);
|
||||
assert!(rendered.contains("go to"), "{rendered}");
|
||||
@@ -721,9 +1173,21 @@ mod tests {
|
||||
assert!(rendered.contains("[esc] close"), "{rendered}");
|
||||
}
|
||||
|
||||
/// While a sigil is in force there is one kind, so the kind header goes.
|
||||
#[test]
|
||||
fn a_sigil_drops_the_kind_header_and_renders_the_chord() {
|
||||
let map = keymap(&[("K", Action::KillHighlighted)]);
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me");
|
||||
typed(&mut bar, ">kill");
|
||||
let rendered = render_to_string(&bar, 90, 20);
|
||||
assert!(rendered.contains("Kill selected session"), "{rendered}");
|
||||
assert!(rendered.contains(" K"), "{rendered}");
|
||||
assert!(!rendered.contains("Commands"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_no_results_state() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
typed(&mut bar, "zzz");
|
||||
let rendered = render_to_string(&bar, 90, 20);
|
||||
assert!(rendered.contains("No matches"), "{rendered}");
|
||||
@@ -732,7 +1196,7 @@ mod tests {
|
||||
/// The matched characters are drawn bold, so the row shows why it matched.
|
||||
#[test]
|
||||
fn matched_ranges_are_styled_apart_from_the_rest_of_the_row() {
|
||||
let mut bar = CommandBar::new(&[named(1, "web-search")], "/home/me");
|
||||
let mut bar = CommandBar::new(&[named(1, "web-search")], &[], "/home/me");
|
||||
typed(&mut bar, "ws");
|
||||
let backend = TestBackend::new(90, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user