Prompts sent while a turn is active are queued on the daemon instead of in the browser's React state. The queue is persisted in the session sidecar and drained as the next turn when the current one completes. Fixes the bug where queued messages were delivered to the wrong session when switching focus in the UI: the client-side queue captured the new sessionId in its drain closure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1523 lines
55 KiB
Rust
1523 lines
55 KiB
Rust
//! 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.
|
||
//!
|
||
//! Three kinds live here: sessions and commands, ranked together by default,
|
||
//! and directories, which need the `~` sigil (#154). `tab` on a session drills
|
||
//! into its action page (#155), the same overlay over three actions. The
|
||
//! leader's literal Ctrl-K (#156) lands 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;
|
||
use ratatui::style::{Modifier, Style};
|
||
use ratatui::text::{Line, Span};
|
||
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, CommandCandidate, DirectoryCandidate, MatchCandidate,
|
||
MatchFieldName, MatchRange, SessionCandidate,
|
||
};
|
||
use crate::overlays::centered;
|
||
use crate::palette;
|
||
use crate::text_input::TextInput;
|
||
|
||
/// 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),
|
||
/// A row of the action page: run this action against this session (#155).
|
||
SessionAction {
|
||
session: Uuid,
|
||
action: SessionActionId,
|
||
},
|
||
}
|
||
|
||
/// 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"). Mirrors the web's `SessionActionId`.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SessionActionId {
|
||
Kill,
|
||
Rename,
|
||
SpawnSimilar,
|
||
}
|
||
|
||
impl SessionActionId {
|
||
/// The row's text, which is also the only field the matcher sees.
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Kill => "Kill session",
|
||
Self::Rename => "Rename session",
|
||
Self::SpawnSimilar => "Spawn similar",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The page's actions in the order an empty query lists them, since the
|
||
/// matcher keeps input order.
|
||
pub const SESSION_ACTIONS: [SessionActionId; 3] = [
|
||
SessionActionId::Kill,
|
||
SessionActionId::Rename,
|
||
SessionActionId::SpawnSimilar,
|
||
];
|
||
|
||
/// 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
|
||
/// opens. Headers are labels, not rows, so they are never in this list.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct BarRow {
|
||
pub kind: CandidateKind,
|
||
pub target: BarTarget,
|
||
/// Primary text — the name as the sidebar shows it.
|
||
pub name: String,
|
||
/// Secondary text: the field the match was found in when that was not the
|
||
/// name, so a session found through its directory says so.
|
||
pub meta: Option<String>,
|
||
/// The field that won, and the ranges inside it. `None` for an empty
|
||
/// query, which matches everything and highlights nothing.
|
||
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 {
|
||
/// Whether the highlight belongs on [`Self::meta`] rather than the name.
|
||
fn meta_is_matched(&self) -> bool {
|
||
!matches!(self.field, None | Some(MatchFieldName::Name))
|
||
}
|
||
}
|
||
|
||
/// One kind's slice of the ranked list, as a header plus the rows under it.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct BarGroup {
|
||
pub kind: CandidateKind,
|
||
pub title: &'static str,
|
||
/// Index of this group's first row in [`CommandBar::rows`].
|
||
pub start: usize,
|
||
pub len: usize,
|
||
}
|
||
|
||
fn kind_title(kind: CandidateKind) -> &'static str {
|
||
match kind {
|
||
CandidateKind::Session => "Sessions",
|
||
CandidateKind::Command => "Commands",
|
||
CandidateKind::Directory => "Directories",
|
||
}
|
||
}
|
||
|
||
/// What a keystroke asked the bar to do.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum BarOutcome {
|
||
/// The bar stays up.
|
||
Stay,
|
||
/// Escape: close and give the keyboard back.
|
||
Close,
|
||
/// Enter on a row: close and open what it points at.
|
||
Open(BarTarget),
|
||
}
|
||
|
||
/// The empty-query order (`docs/COMMAND-BAR.md`, "Empty query"): most recently
|
||
/// focused first; a session that has never been focused sorts after every
|
||
/// session that has, most recently created first among those; and a dead
|
||
/// session sorts after every live one regardless of recency, because a session
|
||
/// you cannot attach to is not where you just were.
|
||
///
|
||
/// This is also the input order for a non-empty query, which is what the
|
||
/// matcher's last tie-break preserves. Mirrors the web's
|
||
/// `orderSessionsForBar`.
|
||
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
|
||
let mut ordered: Vec<&Session> = sessions.iter().collect();
|
||
ordered.sort_by(|a, b| {
|
||
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead);
|
||
dead(a)
|
||
.cmp(&dead(b))
|
||
.then_with(|| {
|
||
a.last_focused_at
|
||
.is_none()
|
||
.cmp(&b.last_focused_at.is_none())
|
||
})
|
||
.then_with(|| match (a.last_focused_at, b.last_focused_at) {
|
||
(Some(x), Some(y)) => y.cmp(&x),
|
||
_ => std::cmp::Ordering::Equal,
|
||
})
|
||
.then_with(|| b.created_at.cmp(&a.created_at))
|
||
});
|
||
ordered
|
||
}
|
||
|
||
/// One session's candidate: the fields `docs/COMMAND-BAR.md`'s candidate table
|
||
/// names, with the directory abbreviated the way the sidebar shows it.
|
||
pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate {
|
||
SessionCandidate {
|
||
name: crate::sidebar::name_for(session),
|
||
directory: session_tree_path(session).map(|path| abbreviate_dir(&path, home_dir)),
|
||
command: (!session.command.is_empty()).then(|| session.command.clone()),
|
||
tool: command_tool(&session.command).map(str::to_string),
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
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>,
|
||
/// The action page while it is up (#155), and the result-list query it was
|
||
/// drilled into from — backing out restores that query, so the round trip
|
||
/// costs the operator nothing.
|
||
page: Option<ActionPage>,
|
||
stashed: String,
|
||
rows: Vec<BarRow>,
|
||
highlight: usize,
|
||
}
|
||
|
||
/// The session action page while it is up: the name the overlay titles itself
|
||
/// with, and the three action rows the query line runs over.
|
||
#[derive(Debug)]
|
||
struct ActionPage {
|
||
name: String,
|
||
actions: Vec<BarEntry>,
|
||
}
|
||
|
||
impl CommandBar {
|
||
/// 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 sessions = ordered
|
||
.iter()
|
||
.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 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(),
|
||
home_dir: home_dir.to_string(),
|
||
sessions,
|
||
commands,
|
||
directories: Vec::new(),
|
||
page: None,
|
||
stashed: String::new(),
|
||
rows: Vec::new(),
|
||
highlight: 0,
|
||
};
|
||
bar.recompute();
|
||
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()
|
||
}
|
||
|
||
pub fn rows(&self) -> &[BarRow] {
|
||
&self.rows
|
||
}
|
||
|
||
pub fn highlight(&self) -> usize {
|
||
self.highlight
|
||
}
|
||
|
||
/// The row Enter would open.
|
||
pub fn selected(&self) -> Option<&BarRow> {
|
||
self.rows.get(self.highlight)
|
||
}
|
||
|
||
/// Group the ranked rows by kind for rendering. Grouping never reorders:
|
||
/// 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.
|
||
pub fn groups(&self) -> Vec<BarGroup> {
|
||
let mut groups: Vec<BarGroup> = Vec::new();
|
||
for (index, row) in self.rows.iter().enumerate() {
|
||
match groups.iter_mut().find(|g| g.kind == row.kind) {
|
||
Some(group) => group.len += 1,
|
||
None => groups.push(BarGroup {
|
||
kind: row.kind,
|
||
title: kind_title(row.kind),
|
||
start: index,
|
||
len: 1,
|
||
}),
|
||
}
|
||
}
|
||
groups
|
||
}
|
||
|
||
/// Move the highlight. It does not wrap: it stops at the first and last
|
||
/// row.
|
||
pub fn move_highlight(&mut self, delta: isize) {
|
||
if self.rows.is_empty() {
|
||
self.highlight = 0;
|
||
return;
|
||
}
|
||
let last = self.rows.len() as isize - 1;
|
||
let next = (self.highlight as isize + delta).clamp(0, last);
|
||
self.highlight = next as usize;
|
||
}
|
||
|
||
/// Feed the bar a key. Everything the key table (`docs/COMMAND-BAR.md`,
|
||
/// "Keys") does not claim is ordinary editing.
|
||
pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome {
|
||
match key {
|
||
// On the action page Escape is one level up, not the way out.
|
||
InputKey::Esc => {
|
||
if self.page.is_some() {
|
||
self.leave_page();
|
||
BarOutcome::Stay
|
||
} else {
|
||
BarOutcome::Close
|
||
}
|
||
}
|
||
InputKey::Enter => match self.selected() {
|
||
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,
|
||
},
|
||
InputKey::Up
|
||
| InputKey::Char {
|
||
c: 'p', ctrl: true, ..
|
||
} => {
|
||
self.move_highlight(-1);
|
||
BarOutcome::Stay
|
||
}
|
||
InputKey::Down
|
||
| InputKey::Char {
|
||
c: 'n', ctrl: true, ..
|
||
} => {
|
||
self.move_highlight(1);
|
||
BarOutcome::Stay
|
||
}
|
||
// Tab drills into the highlighted session's action page (#155);
|
||
// shift-tab is inert rather than a literal tab in the query.
|
||
InputKey::Tab => {
|
||
self.drill_in();
|
||
BarOutcome::Stay
|
||
}
|
||
InputKey::BackTab => BarOutcome::Stay,
|
||
// Ordinary editing, except at an empty query on the action page,
|
||
// where it is the second way back to the result list.
|
||
InputKey::Backspace if self.page.is_some() && self.input.value().is_empty() => {
|
||
self.leave_page();
|
||
BarOutcome::Stay
|
||
}
|
||
other => {
|
||
let before = self.input.value().to_string();
|
||
self.input.handle_key(other);
|
||
if self.input.value() != before {
|
||
self.recompute();
|
||
}
|
||
BarOutcome::Stay
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `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.
|
||
fn drill_in(&mut self) {
|
||
if self.page.is_some() {
|
||
return;
|
||
}
|
||
let Some(row) = self.selected() else {
|
||
return;
|
||
};
|
||
let BarTarget::Session(session) = row.target else {
|
||
return;
|
||
};
|
||
let name = row.name.clone();
|
||
let actions = SESSION_ACTIONS
|
||
.iter()
|
||
.map(|&action| BarEntry {
|
||
candidate: MatchCandidate::Command(CommandCandidate {
|
||
label: action.label().to_string(),
|
||
}),
|
||
target: BarTarget::SessionAction { session, action },
|
||
dead: false,
|
||
chords: Vec::new(),
|
||
})
|
||
.collect();
|
||
self.page = Some(ActionPage { name, actions });
|
||
self.stashed = self.input.value().to_string();
|
||
self.input = TextInput::default();
|
||
self.recompute();
|
||
}
|
||
|
||
/// Escape, or backspace at an empty query: back to the result list with
|
||
/// the query the drill-in was made from.
|
||
fn leave_page(&mut self) {
|
||
self.page = None;
|
||
self.input = TextInput::with_value(std::mem::take(&mut self.stashed));
|
||
self.recompute();
|
||
}
|
||
|
||
/// Whether the action page is up. The session it acts on is in every one
|
||
/// of its rows' targets.
|
||
pub fn on_action_page(&self) -> bool {
|
||
self.page.is_some()
|
||
}
|
||
|
||
/// What the matcher sees. On the action page the whole query is the query:
|
||
/// a sigil narrows the result list to one kind, and the page is one kind
|
||
/// already.
|
||
fn parsed_query(&self) -> BarQuery<'_> {
|
||
match self.page {
|
||
Some(_) => BarQuery {
|
||
sigil: None,
|
||
query: self.input.value(),
|
||
},
|
||
None => parse_bar_query(self.input.value()),
|
||
}
|
||
}
|
||
|
||
/// The sigil in force for the query as it stands.
|
||
pub fn sigil(&self) -> Option<BarSigil> {
|
||
self.parsed_query().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> {
|
||
if let Some(page) = &self.page {
|
||
return page.actions.iter().collect();
|
||
}
|
||
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 parsed = self.parsed_query();
|
||
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 = candidates
|
||
.iter()
|
||
.position(|c| std::ptr::eq(c, result.candidate))
|
||
.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: entry.candidate_kind(),
|
||
target: entry.target.clone(),
|
||
name,
|
||
meta,
|
||
field: result.field,
|
||
ranges: result.ranges.clone(),
|
||
dead: entry.dead,
|
||
chords: entry.chords.clone(),
|
||
}
|
||
})
|
||
.collect()
|
||
};
|
||
self.rows = rows;
|
||
self.highlight = 0;
|
||
}
|
||
|
||
/// Draw the bar and return where the terminal cursor belongs.
|
||
pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> {
|
||
let pal = palette::active();
|
||
let height = parent.height.saturating_sub(4).clamp(5, 20);
|
||
let area = centered(parent, 72, height);
|
||
f.render_widget(Clear, area);
|
||
// The page names the session being acted on, so the target is never
|
||
// ambiguous.
|
||
let title = match (&self.page, self.sigil()) {
|
||
(Some(page), _) => format!(" act on {} ", page.name),
|
||
(None, Some(BarSigil::Command)) => " run a command ".to_string(),
|
||
(None, Some(BarSigil::Directory)) => " spawn in ".to_string(),
|
||
(None, None) => " go to ".to_string(),
|
||
};
|
||
let block = Block::default()
|
||
.title(title)
|
||
.borders(Borders::ALL)
|
||
.style(pal.card.base())
|
||
.border_style(pal.border_style(true));
|
||
let inner = block.inner(area);
|
||
f.render_widget(block, area);
|
||
if inner.height < 3 || inner.width == 0 {
|
||
return None;
|
||
}
|
||
let row = |offset: u16| Rect {
|
||
x: inner.x,
|
||
y: inner.y + offset,
|
||
width: inner.width,
|
||
height: 1,
|
||
};
|
||
|
||
// Query line, then the results, then the key hint on the last row.
|
||
let query = row(0);
|
||
let col = self
|
||
.input
|
||
.render(query, f.buffer_mut(), pal.card.text_style());
|
||
f.render_widget(
|
||
Paragraph::new(Line::from(Span::styled(
|
||
if self.page.is_some() {
|
||
"[enter] run [esc] back"
|
||
} else {
|
||
"[enter] open [esc] close"
|
||
},
|
||
pal.card.dim_style(),
|
||
))),
|
||
row(inner.height - 1),
|
||
);
|
||
|
||
let list_height = inner.height.saturating_sub(2) as usize;
|
||
if list_height > 0 {
|
||
for (offset, line) in self.visible_lines(list_height).into_iter().enumerate() {
|
||
f.render_widget(Paragraph::new(line), row(1 + offset as u16));
|
||
}
|
||
}
|
||
Some((col, query.y))
|
||
}
|
||
|
||
/// The result lines that fit in `height`, scrolled so the highlighted row
|
||
/// is on screen.
|
||
fn visible_lines(&self, height: usize) -> Vec<Line<'static>> {
|
||
let pal = palette::active();
|
||
if self.rows.is_empty() {
|
||
return vec![Line::from(Span::styled(
|
||
"No matches",
|
||
pal.card.muted_style(),
|
||
))];
|
||
}
|
||
// Headers and rows, in one list, remembering which line the highlight
|
||
// 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. The action
|
||
// page is one kind too.
|
||
let headers = self.sigil().is_none() && self.page.is_none();
|
||
for group in self.groups() {
|
||
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();
|
||
}
|
||
lines.push(self.row_line(index));
|
||
}
|
||
}
|
||
let scroll = highlight_line.saturating_sub(height.saturating_sub(1));
|
||
lines.into_iter().skip(scroll).take(height).collect()
|
||
}
|
||
|
||
fn row_line(&self, index: usize) -> Line<'static> {
|
||
let pal = palette::active();
|
||
let row = &self.rows[index];
|
||
let selected = index == self.highlight;
|
||
let surface = pal.surface(selected);
|
||
let base = if row.dead {
|
||
surface.dim_style()
|
||
} else {
|
||
surface.text_style()
|
||
};
|
||
let hit = base.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
|
||
|
||
let mut spans = vec![Span::styled(if selected { "› " } else { " " }, base)];
|
||
let name_ranges: &[MatchRange] = if row.meta_is_matched() {
|
||
&[]
|
||
} else {
|
||
&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);
|
||
spans.push(Span::styled(" ", base));
|
||
let meta_ranges: &[MatchRange] = if row.meta_is_matched() {
|
||
&row.ranges
|
||
} else {
|
||
&[]
|
||
};
|
||
spans.extend(highlighted_spans(meta, meta_ranges, meta_style, meta_hit));
|
||
}
|
||
Line::from(spans)
|
||
}
|
||
}
|
||
|
||
/// 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. Mirrors the web's `metaText`.
|
||
fn meta_text(session: &SessionCandidate, field: Option<MatchFieldName>) -> Option<String> {
|
||
match field {
|
||
Some(MatchFieldName::Directory) => session.directory.clone(),
|
||
Some(MatchFieldName::Command) => session.command.clone(),
|
||
Some(MatchFieldName::Tool) => session.tool.clone(),
|
||
_ => session.directory.clone(),
|
||
}
|
||
}
|
||
|
||
/// Split `text` into styled spans, with the matcher's `[start, end)` character
|
||
/// ranges carrying `hit` and everything else `base`.
|
||
fn highlighted_spans(
|
||
text: &str,
|
||
ranges: &[MatchRange],
|
||
base: Style,
|
||
hit: Style,
|
||
) -> Vec<Span<'static>> {
|
||
let chars: Vec<char> = text.chars().collect();
|
||
let slice = |from: usize, to: usize| -> String {
|
||
chars[from.min(chars.len())..to.min(chars.len())]
|
||
.iter()
|
||
.collect()
|
||
};
|
||
let mut spans = Vec::new();
|
||
let mut cursor = 0usize;
|
||
for &(start, end) in ranges {
|
||
if start > cursor {
|
||
spans.push(Span::styled(slice(cursor, start), base));
|
||
}
|
||
spans.push(Span::styled(slice(start, end), hit));
|
||
cursor = end;
|
||
}
|
||
if cursor < chars.len() {
|
||
spans.push(Span::styled(slice(cursor, chars.len()), base));
|
||
}
|
||
spans
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::{DateTime, TimeZone, Utc};
|
||
use ratatui::backend::TestBackend;
|
||
use ratatui::Terminal;
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
|
||
fn at(secs: i64) -> DateTime<Utc> {
|
||
Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap()
|
||
}
|
||
|
||
fn session(n: u128, command: &str) -> Session {
|
||
Session {
|
||
view: Default::default(),
|
||
id: Uuid::from_u128(n),
|
||
cwd: PathBuf::from("/home/me/dev/api"),
|
||
command: command.to_string(),
|
||
env: HashMap::new(),
|
||
metadata: HashMap::new(),
|
||
worktree: None,
|
||
status: SessionStatus::Running,
|
||
created_at: at(0),
|
||
foreground: None,
|
||
activity: Default::default(),
|
||
activity_detail: None,
|
||
last_activity_at: at(0),
|
||
activity_since: at(0),
|
||
current_cwd: None,
|
||
blocked_work: None,
|
||
last_focused_at: None,
|
||
acp_session_id: None,
|
||
auto_approve: false,
|
||
queued_prompts: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn named(n: u128, name: &str) -> Session {
|
||
let mut s = session(n, "bash");
|
||
s.metadata
|
||
.insert("name".into(), serde_json::Value::String(name.to_string()));
|
||
s
|
||
}
|
||
|
||
fn ids(rows: &[BarRow]) -> Vec<Uuid> {
|
||
rows.iter()
|
||
.map(|r| match r.target {
|
||
BarTarget::Session(id) => id,
|
||
ref other => panic!("expected a session row, got {other:?}"),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn typed(bar: &mut CommandBar, text: &str) {
|
||
for c in text.chars() {
|
||
bar.handle_key(&InputKey::Char {
|
||
c,
|
||
ctrl: false,
|
||
alt: false,
|
||
shift: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn empty_query_lists_sessions_most_recently_focused_first() {
|
||
let mut a = named(1, "alpha");
|
||
a.last_focused_at = Some(at(10));
|
||
let mut b = named(2, "bravo");
|
||
b.last_focused_at = Some(at(30));
|
||
let mut c = named(3, "charlie");
|
||
c.last_focused_at = Some(at(20));
|
||
|
||
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)]
|
||
);
|
||
// Every row matches with no ranges, so nothing is highlighted inside.
|
||
assert!(bar.rows().iter().all(|r| r.ranges.is_empty()));
|
||
}
|
||
|
||
#[test]
|
||
fn never_focused_sessions_sort_after_focused_ones_newest_first() {
|
||
let mut focused = named(1, "focused");
|
||
focused.last_focused_at = Some(at(5));
|
||
let mut older = named(2, "older");
|
||
older.created_at = at(10);
|
||
let mut newer = named(3, "newer");
|
||
newer.created_at = at(20);
|
||
|
||
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)]
|
||
);
|
||
}
|
||
|
||
/// A session you cannot attach to is not where you just were.
|
||
#[test]
|
||
fn dead_sessions_sort_after_live_ones_however_recent() {
|
||
let mut dead = named(1, "dead");
|
||
dead.status = SessionStatus::Dead;
|
||
dead.last_focused_at = Some(at(99));
|
||
let live = named(2, "live");
|
||
|
||
let bar = CommandBar::new(&[dead, live], &[], "/home/me");
|
||
assert_eq!(
|
||
ids(bar.rows()),
|
||
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
|
||
);
|
||
assert!(bar.rows()[1].dead);
|
||
}
|
||
|
||
#[test]
|
||
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
||
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!(
|
||
ids(bar.rows()),
|
||
vec![Uuid::from_u128(1), Uuid::from_u128(2)]
|
||
);
|
||
assert_eq!(bar.rows()[0].ranges, vec![(0, 1), (4, 5)]);
|
||
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Name));
|
||
}
|
||
|
||
#[test]
|
||
fn a_query_matching_nothing_leaves_no_rows() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
typed(&mut bar, "zzz");
|
||
assert!(bar.rows().is_empty());
|
||
assert!(bar.selected().is_none());
|
||
}
|
||
|
||
/// A session found through its directory highlights inside the directory,
|
||
/// which is also what the row's meta text shows.
|
||
#[test]
|
||
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");
|
||
typed(&mut bar, "api");
|
||
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory));
|
||
assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api"));
|
||
assert_eq!(bar.rows()[0].ranges, vec![(6, 9)]);
|
||
}
|
||
|
||
/// The candidate's `tool` is the mark the card's icon stands for, so
|
||
/// `claude` reaches a session whose name says nothing about it.
|
||
#[test]
|
||
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");
|
||
typed(&mut bar, "claude");
|
||
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]);
|
||
assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool));
|
||
}
|
||
|
||
#[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 groups = bar.groups();
|
||
assert_eq!(groups.len(), 1);
|
||
assert_eq!(groups[0].title, "Sessions");
|
||
assert_eq!(groups[0].kind, CandidateKind::Session);
|
||
assert_eq!((groups[0].start, groups[0].len), (0, 2));
|
||
// The highlight starts on the globally best row.
|
||
assert_eq!(bar.highlight(), 0);
|
||
}
|
||
|
||
#[test]
|
||
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);
|
||
assert_eq!(bar.highlight(), 1);
|
||
bar.handle_key(&InputKey::Char {
|
||
c: 'n',
|
||
ctrl: true,
|
||
alt: false,
|
||
shift: false,
|
||
});
|
||
assert_eq!(bar.highlight(), 2);
|
||
// Stops at the last row rather than wrapping to the first.
|
||
bar.handle_key(&InputKey::Down);
|
||
assert_eq!(bar.highlight(), 2);
|
||
bar.handle_key(&InputKey::Char {
|
||
c: 'p',
|
||
ctrl: true,
|
||
alt: false,
|
||
shift: false,
|
||
});
|
||
assert_eq!(bar.highlight(), 1);
|
||
bar.handle_key(&InputKey::Up);
|
||
bar.handle_key(&InputKey::Up);
|
||
assert_eq!(bar.highlight(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_opens_the_highlighted_session_and_escape_closes() {
|
||
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),
|
||
BarOutcome::Open(BarTarget::Session(Uuid::from_u128(2)))
|
||
);
|
||
assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_with_no_results_leaves_the_bar_up() {
|
||
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");
|
||
bar.handle_key(&InputKey::Down);
|
||
assert_eq!(bar.highlight(), 1);
|
||
typed(&mut bar, "alp");
|
||
assert_eq!(bar.query(), "alp");
|
||
assert_eq!(bar.highlight(), 0);
|
||
bar.handle_key(&InputKey::Backspace);
|
||
assert_eq!(bar.query(), "al");
|
||
assert_eq!(bar.rows().len(), 2);
|
||
}
|
||
|
||
/// Backspacing back to an empty query is not a special key: the list
|
||
/// becomes the recency order again.
|
||
#[test]
|
||
fn backspacing_to_an_empty_query_restores_the_recency_order() {
|
||
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");
|
||
typed(&mut bar, "alpi");
|
||
assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]);
|
||
for _ in 0..4 {
|
||
bar.handle_key(&InputKey::Backspace);
|
||
}
|
||
assert_eq!(bar.query(), "");
|
||
assert_eq!(
|
||
ids(bar.rows()),
|
||
vec![Uuid::from_u128(1), Uuid::from_u128(2)]
|
||
);
|
||
}
|
||
|
||
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();
|
||
terminal
|
||
.draw(|f| {
|
||
bar.render(f, f.area());
|
||
})
|
||
.unwrap();
|
||
let buf = terminal.backend().buffer().clone();
|
||
let mut out = String::new();
|
||
for y in 0..buf.area.height {
|
||
for x in 0..buf.area.width {
|
||
out.push_str(buf[(x, y)].symbol());
|
||
}
|
||
out.push('\n');
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn renders_the_query_the_headers_and_the_rows() {
|
||
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}");
|
||
assert!(rendered.contains("alp"), "{rendered}");
|
||
assert!(rendered.contains("Sessions"), "{rendered}");
|
||
assert!(rendered.contains("alpha"), "{rendered}");
|
||
assert!(rendered.contains("alpine"), "{rendered}");
|
||
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");
|
||
typed(&mut bar, "zzz");
|
||
let rendered = render_to_string(&bar, 90, 20);
|
||
assert!(rendered.contains("No matches"), "{rendered}");
|
||
}
|
||
|
||
/// 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");
|
||
typed(&mut bar, "ws");
|
||
let backend = TestBackend::new(90, 20);
|
||
let mut terminal = Terminal::new(backend).unwrap();
|
||
terminal
|
||
.draw(|f| {
|
||
bar.render(f, f.area());
|
||
})
|
||
.unwrap();
|
||
let buf = terminal.backend().buffer().clone();
|
||
let mut bold: Vec<String> = Vec::new();
|
||
for y in 0..buf.area.height {
|
||
for x in 0..buf.area.width {
|
||
let cell = &buf[(x, y)];
|
||
if cell.style().add_modifier.contains(Modifier::BOLD)
|
||
&& cell.style().add_modifier.contains(Modifier::UNDERLINED)
|
||
{
|
||
bold.push(cell.symbol().to_string());
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}");
|
||
}
|
||
|
||
// ---- the session action page (#155) ----
|
||
|
||
fn targets(rows: &[BarRow]) -> Vec<BarTarget> {
|
||
rows.iter().map(|r| r.target.clone()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn tab_on_a_session_opens_its_action_page() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
bar.handle_key(&InputKey::Tab);
|
||
assert!(bar.on_action_page());
|
||
assert_eq!(
|
||
labels(bar.rows()),
|
||
vec!["Kill session", "Rename session", "Spawn similar"]
|
||
);
|
||
assert_eq!(
|
||
targets(bar.rows()),
|
||
SESSION_ACTIONS
|
||
.iter()
|
||
.map(|&action| BarTarget::SessionAction {
|
||
session: Uuid::from_u128(1),
|
||
action,
|
||
})
|
||
.collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
/// Tab has no effect on any other kind, and none on the page itself, which
|
||
/// has no session rows.
|
||
#[test]
|
||
fn tab_on_a_command_or_directory_row_does_nothing() {
|
||
let keymap = vec![("x".to_string(), Action::KillHighlighted)];
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &keymap, "/home/me");
|
||
typed(&mut bar, ">");
|
||
bar.handle_key(&InputKey::Tab);
|
||
assert!(!bar.on_action_page());
|
||
assert_eq!(bar.query(), ">");
|
||
|
||
let mut bar = CommandBar::new(&[], &[], "/home/me");
|
||
bar.set_directories(&[PathBuf::from("/home/me/dev/api")]);
|
||
typed(&mut bar, "~");
|
||
bar.handle_key(&InputKey::Tab);
|
||
assert!(!bar.on_action_page());
|
||
}
|
||
|
||
#[test]
|
||
fn tab_on_the_action_page_does_not_drill_further() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
bar.handle_key(&InputKey::Tab);
|
||
bar.handle_key(&InputKey::Tab);
|
||
assert!(bar.on_action_page());
|
||
assert_eq!(labels(bar.rows()).len(), 3);
|
||
}
|
||
|
||
/// The page drills into the highlighted session, not the first one.
|
||
#[test]
|
||
fn the_page_acts_on_the_highlighted_session() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
|
||
bar.move_highlight(1);
|
||
bar.handle_key(&InputKey::Tab);
|
||
assert_eq!(
|
||
bar.rows()[0].target,
|
||
BarTarget::SessionAction {
|
||
session: Uuid::from_u128(2),
|
||
action: SessionActionId::Kill,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_action_page_stays_fuzzy_searchable() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
bar.handle_key(&InputKey::Tab);
|
||
typed(&mut bar, "ren");
|
||
assert_eq!(labels(bar.rows()), vec!["Rename session"]);
|
||
assert_eq!(
|
||
bar.selected().map(|r| r.target.clone()),
|
||
Some(BarTarget::SessionAction {
|
||
session: Uuid::from_u128(1),
|
||
action: SessionActionId::Rename,
|
||
})
|
||
);
|
||
}
|
||
|
||
/// A sigil narrows the result list to one kind; the page is one kind
|
||
/// already, so `>` there is a query character like any other.
|
||
#[test]
|
||
fn a_sigil_is_an_ordinary_character_on_the_action_page() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
bar.handle_key(&InputKey::Tab);
|
||
typed(&mut bar, ">");
|
||
assert_eq!(bar.sigil(), None);
|
||
assert!(bar.rows().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn escape_returns_to_the_result_list_with_the_query_intact() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
|
||
typed(&mut bar, "alp");
|
||
assert_eq!(bar.handle_key(&InputKey::Tab), BarOutcome::Stay);
|
||
assert_eq!(bar.query(), "", "the page starts on an empty query");
|
||
// Escape is one level up, not the way out.
|
||
assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Stay);
|
||
assert!(!bar.on_action_page());
|
||
assert_eq!(bar.query(), "alp");
|
||
assert_eq!(labels(bar.rows()), vec!["alpha"]);
|
||
// Off the page it closes the bar again.
|
||
assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close);
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_at_an_empty_query_returns_to_the_result_list() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
typed(&mut bar, "alp");
|
||
bar.handle_key(&InputKey::Tab);
|
||
bar.handle_key(&InputKey::Backspace);
|
||
assert!(!bar.on_action_page());
|
||
assert_eq!(bar.query(), "alp");
|
||
}
|
||
|
||
/// Backspace with something typed is ordinary editing of the page's own
|
||
/// query, not the way out.
|
||
#[test]
|
||
fn backspace_on_a_non_empty_page_query_edits_it() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
typed(&mut bar, "alp");
|
||
bar.handle_key(&InputKey::Tab);
|
||
typed(&mut bar, "kx");
|
||
bar.handle_key(&InputKey::Backspace);
|
||
assert!(bar.on_action_page());
|
||
assert_eq!(bar.query(), "k");
|
||
assert_eq!(labels(bar.rows()), vec!["Kill session"]);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_on_the_page_opens_the_action_for_that_session() {
|
||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||
bar.handle_key(&InputKey::Tab);
|
||
typed(&mut bar, "spawn");
|
||
assert_eq!(
|
||
bar.handle_key(&InputKey::Enter),
|
||
BarOutcome::Open(BarTarget::SessionAction {
|
||
session: Uuid::from_u128(1),
|
||
action: SessionActionId::SpawnSimilar,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_action_page_titles_itself_with_the_session_it_acts_on() {
|
||
let mut bar = CommandBar::new(&[named(1, "amber-falcon")], &[], "/home/me");
|
||
bar.handle_key(&InputKey::Tab);
|
||
let rendered = render_to_string(&bar, 90, 20);
|
||
assert!(rendered.contains("act on amber-falcon"), "{rendered}");
|
||
assert!(rendered.contains("Kill session"), "{rendered}");
|
||
// One kind, so the group header is dropped the way a sigil drops it.
|
||
assert!(!rendered.contains("Commands"), "{rendered}");
|
||
}
|
||
}
|