Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b260b5c20 | ||
|
|
3eb1d7ddbe |
+145
-15
@@ -23,7 +23,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::catalog::CatalogStore;
|
||||
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
|
||||
use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId};
|
||||
use crate::config::TuiConfig;
|
||||
use crate::daemon_link::DaemonLink;
|
||||
use crate::draft::{DraftInputResult, DraftPane};
|
||||
@@ -479,6 +479,25 @@ impl App {
|
||||
self.close_command_bar();
|
||||
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
|
||||
}
|
||||
// The action page raises the flow this client already has
|
||||
// — for kill that is `KillConfirm`, which the TUI puts in
|
||||
// front of every kill (#160). The bar names the target;
|
||||
// it never reimplements the flow.
|
||||
BarOutcome::Open(BarTarget::SessionAction { session, action }) => {
|
||||
self.close_command_bar();
|
||||
let effects = match action {
|
||||
SessionActionId::Kill => {
|
||||
self.confirm_kill(session);
|
||||
Vec::new()
|
||||
}
|
||||
SessionActionId::Rename => {
|
||||
self.prompt_rename(session);
|
||||
Vec::new()
|
||||
}
|
||||
SessionActionId::SpawnSimilar => self.open_spawn_similar(session),
|
||||
};
|
||||
(OverlayDecision::Consumed, effects)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(OverlayKind::Help) => {
|
||||
@@ -555,24 +574,12 @@ impl App {
|
||||
}
|
||||
Action::KillHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||
self.confirm_kill(sid);
|
||||
}
|
||||
}
|
||||
Action::RenameHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
// Pre-fill with the custom name only: a session that has
|
||||
// none starts empty rather than with its command already
|
||||
// typed in, which Enter would then pin as the name.
|
||||
self.rename_input = TextInput::with_value(
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.and_then(crate::sidebar::custom_name_for)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||
self.prompt_rename(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,6 +865,30 @@ impl App {
|
||||
Some(None)
|
||||
}
|
||||
|
||||
/// Raise the kill confirmation for one session (`docs/WORKTREES.md`).
|
||||
/// Shared by the keymap's own kill and the command bar's action page
|
||||
/// (#155), which picks the action and hands off rather than reimplementing
|
||||
/// it; the blockers the dialog names are read at render time.
|
||||
fn confirm_kill(&mut self, sid: Uuid) {
|
||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||
}
|
||||
|
||||
/// Raise the rename prompt for one session, pre-filled with its custom
|
||||
/// name only: a session that has none starts empty rather than with its
|
||||
/// command already typed in, which Enter would then pin as the name.
|
||||
fn prompt_rename(&mut self, sid: Uuid) {
|
||||
self.rename_input = TextInput::with_value(
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.and_then(crate::sidebar::custom_name_for)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||
}
|
||||
|
||||
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
||||
/// project of the session the operator was last on — so `n` then Enter
|
||||
/// spawns another session there, the way "spawn similar" would. A
|
||||
@@ -871,6 +902,30 @@ impl App {
|
||||
self.state.open_spawn_modal();
|
||||
}
|
||||
|
||||
/// "Spawn similar" (#155): the spawn form seeded from one session's
|
||||
/// directory and command, rather than from whatever the operator was last
|
||||
/// looking at. Same base-repository rule as the plain seed, and the name
|
||||
/// is left out on purpose — a clone is a new session and takes its own
|
||||
/// generated handle, like `web/src/spawnSimilar.ts`.
|
||||
fn open_spawn_similar(&mut self, sid: Uuid) -> Vec<Effect> {
|
||||
let session = self
|
||||
.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.cloned();
|
||||
self.open_spawn_modal();
|
||||
if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) {
|
||||
draft.seed_cwd = Some(match &session.worktree {
|
||||
Some(wt) => wt.base_repo.clone(),
|
||||
None => session.cwd.clone(),
|
||||
});
|
||||
draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone());
|
||||
}
|
||||
vec![Effect::LoadDraft]
|
||||
}
|
||||
|
||||
/// The project of the session the operator was last on — its base
|
||||
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
||||
@@ -3379,4 +3434,79 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the session action page (#155) ----
|
||||
|
||||
/// Drill into the second row's session, then pick an action by name.
|
||||
fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec<Effect>) {
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
app.handle_overlay_input(&InputKey::Down);
|
||||
app.handle_overlay_input(&InputKey::Tab);
|
||||
type_into_bar(app, action);
|
||||
app.handle_overlay_input(&InputKey::Enter)
|
||||
}
|
||||
|
||||
/// The bar picks the action and hands off: kill goes through the confirm
|
||||
/// this client puts in front of every kill, not straight to the daemon.
|
||||
#[test]
|
||||
fn the_action_page_raises_the_existing_kill_confirm() {
|
||||
let mut app = make_app();
|
||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||
let (decision, effects) = drill_and_pick(&mut app, "kill");
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert!(effects.is_empty(), "the confirm is the whole effect");
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha)));
|
||||
assert!(app.command_bar.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_action_page_raises_the_existing_rename_prompt_prefilled() {
|
||||
let mut app = make_app();
|
||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||
let (decision, effects) = drill_and_pick(&mut app, "rename");
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha)));
|
||||
assert_eq!(app.rename_input.value(), "alpha");
|
||||
assert!(app.command_bar.is_none());
|
||||
}
|
||||
|
||||
/// Spawn similar opens the spawn form seeded from that session's directory
|
||||
/// and command — not from whatever the operator was last looking at.
|
||||
#[test]
|
||||
fn the_action_page_raises_the_spawn_form_seeded_from_the_session() {
|
||||
let mut app = make_app();
|
||||
seed_two_sessions(&mut app);
|
||||
let (decision, effects) = drill_and_pick(&mut app, "spawn");
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert_eq!(effects, vec![Effect::LoadDraft]);
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/")));
|
||||
assert_eq!(draft.seed_command.as_deref(), Some("bash"));
|
||||
assert!(app.command_bar.is_none());
|
||||
}
|
||||
|
||||
/// Backing out is free: the result-list query survives the round trip.
|
||||
#[test]
|
||||
fn escape_on_the_action_page_returns_to_the_result_list() {
|
||||
let mut app = make_app();
|
||||
seed_two_sessions(&mut app);
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
type_into_bar(&mut app, "alp");
|
||||
app.handle_overlay_input(&InputKey::Tab);
|
||||
assert!(app.command_bar.as_ref().unwrap().on_action_page());
|
||||
|
||||
let (decision, _) = app.handle_overlay_input(&InputKey::Esc);
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
let bar = app.command_bar.as_ref().expect("escape backs out, not out");
|
||||
assert!(!bar.on_action_page());
|
||||
assert_eq!(bar.query(), "alp");
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
||||
|
||||
// A second escape is the way out again.
|
||||
app.handle_overlay_input(&InputKey::Esc);
|
||||
assert!(app.command_bar.is_none());
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! 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). The session action page
|
||||
//! (#155) and the leader's literal Ctrl-K (#156) land later.
|
||||
//! 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
|
||||
@@ -46,8 +47,42 @@ pub enum BarTarget {
|
||||
/// 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)]
|
||||
@@ -255,10 +290,23 @@ pub struct CommandBar {
|
||||
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
|
||||
@@ -293,6 +341,8 @@ impl CommandBar {
|
||||
sessions,
|
||||
commands,
|
||||
directories: Vec::new(),
|
||||
page: None,
|
||||
stashed: String::new(),
|
||||
rows: Vec::new(),
|
||||
highlight: 0,
|
||||
};
|
||||
@@ -381,7 +431,15 @@ impl CommandBar {
|
||||
/// "Keys") does not claim is ordinary editing.
|
||||
pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome {
|
||||
match key {
|
||||
InputKey::Esc => BarOutcome::Close,
|
||||
// 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
|
||||
@@ -402,9 +460,19 @@ impl CommandBar {
|
||||
self.move_highlight(1);
|
||||
BarOutcome::Stay
|
||||
}
|
||||
// Tab drills into the highlighted session's action page (#155).
|
||||
// Until then it is inert rather than a literal tab in the query.
|
||||
InputKey::Tab | InputKey::BackTab => 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);
|
||||
@@ -416,15 +484,75 @@ impl CommandBar {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
parse_bar_query(self.input.value()).sigil
|
||||
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(),
|
||||
@@ -438,7 +566,7 @@ impl CommandBar {
|
||||
/// different result.
|
||||
fn recompute(&mut self) {
|
||||
let rows: Vec<BarRow> = {
|
||||
let parsed = parse_bar_query(self.input.value());
|
||||
let parsed = self.parsed_query();
|
||||
let entries = self.active_entries(parsed);
|
||||
let candidates: Vec<MatchCandidate> =
|
||||
entries.iter().map(|e| e.candidate.clone()).collect();
|
||||
@@ -482,12 +610,16 @@ impl CommandBar {
|
||||
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(match self.sigil() {
|
||||
Some(BarSigil::Command) => " run a command ",
|
||||
Some(BarSigil::Directory) => " spawn in ",
|
||||
None => " go to ",
|
||||
})
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.style(pal.card.base())
|
||||
.border_style(pal.border_style(true));
|
||||
@@ -510,7 +642,11 @@ impl CommandBar {
|
||||
.render(query, f.buffer_mut(), pal.card.text_style());
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"[enter] open [esc] close",
|
||||
if self.page.is_some() {
|
||||
"[enter] run [esc] back"
|
||||
} else {
|
||||
"[enter] open [esc] close"
|
||||
},
|
||||
pal.card.dim_style(),
|
||||
))),
|
||||
row(inner.height - 1),
|
||||
@@ -540,8 +676,9 @@ impl CommandBar {
|
||||
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();
|
||||
// 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(
|
||||
@@ -1219,4 +1356,163 @@ mod tests {
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,10 @@ pub struct DraftPane {
|
||||
/// respawns there without touching the picker. Consumed by the first
|
||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||
pub seed_cwd: Option<PathBuf>,
|
||||
/// Command to preselect once the preset list lands — "spawn similar"'s
|
||||
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
|
||||
/// `None` once applied or when nothing seeded it.
|
||||
pub seed_command: Option<String>,
|
||||
/// The repository picked in step one ([`group_key`] of its main box),
|
||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||
/// cached, so a project-list refresh can't leave it stale.
|
||||
@@ -291,6 +295,7 @@ impl Default for DraftPane {
|
||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
seed_command: None,
|
||||
custom_command: crate::text_input::TextInput::default(),
|
||||
groups: Vec::new(),
|
||||
filter: crate::text_input::TextInput::default(),
|
||||
@@ -342,6 +347,27 @@ impl DraftPane {
|
||||
.iter()
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
/// daemon offers one, otherwise the custom escape hatch with the command
|
||||
/// typed in.
|
||||
fn apply_seed_command(&mut self) {
|
||||
let Some(command) = self.seed_command.take() else {
|
||||
return;
|
||||
};
|
||||
match self.presets.iter().position(|p| p.command == command) {
|
||||
Some(index) => self.selected_preset = index,
|
||||
None => {
|
||||
self.selected_preset = self
|
||||
.presets
|
||||
.iter()
|
||||
.position(|p| p.command.is_empty())
|
||||
.unwrap_or(self.selected_preset);
|
||||
self.custom_command = crate::text_input::TextInput::with_value(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||
@@ -1688,6 +1714,48 @@ mod tests {
|
||||
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
||||
}
|
||||
|
||||
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
|
||||
/// the tile that runs it when the daemon offers one.
|
||||
#[test]
|
||||
fn a_seeded_command_selects_its_own_preset() {
|
||||
let mut draft = DraftPane {
|
||||
seed_command: Some("aider".into()),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert!(!draft.is_custom());
|
||||
assert_eq!(draft.seed_command, None, "consumed by the first load");
|
||||
}
|
||||
|
||||
/// A command no preset runs falls back to the custom tile with the command
|
||||
/// typed in, so the clone still spawns what it cloned.
|
||||
#[test]
|
||||
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
|
||||
let mut draft = DraftPane {
|
||||
seed_command: Some("bash -l".into()),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
}]);
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The sidebar's half of the command bar's action page (#151): the bar picks
|
||||
// kill or rename and raises the flow the card already has, so what the page
|
||||
// runs is what the ✕ button and the `r` key run.
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
});
|
||||
|
||||
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector(".kill-dialog")?.remove();
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: { name: "amber-falcon" },
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function mount(s: Session): HTMLElement {
|
||||
const handle = renderSidebar(storeWith([s]));
|
||||
mounted = handle;
|
||||
document.body.appendChild(handle.el);
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function raise(name: string, s: Session): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: s }));
|
||||
}
|
||||
|
||||
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("kill from the action page", () => {
|
||||
it("goes through the daemon, without a first confirmation", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
const s = session();
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
|
||||
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
|
||||
window.fetch = ((url: string) => {
|
||||
if (String(url).includes("force=true")) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "kill_guarded",
|
||||
message: "worktree has unsaved work",
|
||||
unsaved_work: { untracked_files: 1 },
|
||||
},
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const s = session({ blocked_work: { untracked_files: 1 } });
|
||||
mount(s);
|
||||
raise(KILL_SESSION_EVENT, s);
|
||||
await flush();
|
||||
const dialog = document.querySelector(".kill-dialog");
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
|
||||
expect(dialog?.textContent).toContain("1 untracked file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename from the action page", () => {
|
||||
it("expands the folder hiding the card, since the prompt lives in the card", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
|
||||
folder?.click();
|
||||
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("raises the card's own prompt, pre-filled with the session's name", () => {
|
||||
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
|
||||
const s = session();
|
||||
const el = mount(s);
|
||||
raise(RENAME_SESSION_EVENT, s);
|
||||
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||
expect(input?.value).toBe("amber-falcon");
|
||||
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { CommandSet, defineCommands } from "./commands.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
abbreviateCwd,
|
||||
abbreviateFolderName,
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
sessionBlockers,
|
||||
isProvisionalName,
|
||||
sessionName,
|
||||
sessionTreePath,
|
||||
} from "./types.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import {
|
||||
@@ -50,6 +52,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** Ask the sidebar to kill a session — the command bar's action page picking
|
||||
* `kill` (#151). Detail is the session. Handled here, so the kill goes
|
||||
* through the same confirm-and-blockers flow the card's ✕ does
|
||||
* (`docs/WORKTREES.md`); the bar never reimplements it. */
|
||||
export const KILL_SESSION_EVENT = "catalog-kill-session";
|
||||
|
||||
/** Ask the sidebar to rename a session — the command bar's action page picking
|
||||
* `rename` (#151). Detail is the session. The prompt is the card's own inline
|
||||
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
|
||||
export const RENAME_SESSION_EVENT = "catalog-rename-session";
|
||||
|
||||
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||
* hash navigation, but a split/drag can move focus without ever touching
|
||||
* `location.hash` — so a click back onto a session whose hash still
|
||||
@@ -745,6 +758,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// The command bar's action page (#151) picks an action and hands it here:
|
||||
// both flows are the ones the card already has, reached by session rather
|
||||
// than by row.
|
||||
const killHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
void requestKill(s, sessionName(s));
|
||||
};
|
||||
const renameHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
const row = rowForSession(s);
|
||||
if (row) beginRename(row, s);
|
||||
};
|
||||
document.addEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
|
||||
/** The card a session is rendered as, expanding whatever folder hides it
|
||||
* first: the rename prompt lives inside the card, so a collapsed folder
|
||||
* would otherwise swallow the request. */
|
||||
function rowForSession(s: Session): HTMLElement | null {
|
||||
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
|
||||
const found = find();
|
||||
if (found) return found;
|
||||
// Folder paths are the abbreviated directory's segments joined back up
|
||||
// (see buildTree), so the session's own path is normalised the same way
|
||||
// before it is compared against them.
|
||||
const cwd = sessionTreePath(s);
|
||||
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
|
||||
for (const folder of [...collapsed]) {
|
||||
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
|
||||
}
|
||||
rerenderHandler();
|
||||
return find();
|
||||
}
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
// multi-view controller can seed the spawn modal with the session's dir/command.
|
||||
|
||||
@@ -794,6 +841,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
|
||||
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
|
||||
+180
-1
@@ -1,7 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
@@ -15,6 +20,7 @@ import {
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionBarCandidates,
|
||||
sessionActionCandidates,
|
||||
sessionCandidate,
|
||||
SPAWN_DIRECTORY_EVENT,
|
||||
} from "./commandBar.js";
|
||||
@@ -542,3 +548,176 @@ describe("the focus round-trip over a pane (#152)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page (#151)", () => {
|
||||
const sessions = [
|
||||
session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
last_focused_at: "2026-01-01T11:00:00Z",
|
||||
}),
|
||||
session("worker", {
|
||||
metadata: { name: "worker" },
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
];
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [],
|
||||
onOpenSession: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
function headerText(): string | undefined {
|
||||
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
expect(activeName()).toBe("Kill session");
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
type("spwn");
|
||||
expect(rowNames()).toEqual(["Spawn similar"]);
|
||||
type("zzz");
|
||||
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
|
||||
onOpenSession: () => {},
|
||||
});
|
||||
type(">");
|
||||
const before = rowNames();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(before);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
|
||||
type("~");
|
||||
await Promise.resolve();
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["~/dev/api"]);
|
||||
expect(headerText()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(headerText()).toContain("worker");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(true);
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
open();
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type("");
|
||||
press("Backspace");
|
||||
expect(headerText()).toBeUndefined();
|
||||
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
type("ren");
|
||||
press("Backspace");
|
||||
expect(headerText()).toContain("api");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
open();
|
||||
press("Tab");
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
const seen: string[] = [];
|
||||
for (const [hook, key] of [
|
||||
["onKillSession", "Kill session"],
|
||||
["onRenameSession", "Rename session"],
|
||||
["onSpawnSimilar", "Spawn similar"],
|
||||
] as const) {
|
||||
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
type(key);
|
||||
press("Enter");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
"onKillSession:worker",
|
||||
"onRenameSession:worker",
|
||||
"onSpawnSimilar:worker",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
const seen: string[] = [];
|
||||
const listeners: Array<[string, EventListener]> = [
|
||||
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
|
||||
];
|
||||
for (const [name, fn] of listeners) document.addEventListener(name, fn);
|
||||
try {
|
||||
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
|
||||
open();
|
||||
press("Tab");
|
||||
type(action);
|
||||
press("Enter");
|
||||
}
|
||||
} finally {
|
||||
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
|
||||
}
|
||||
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
|
||||
});
|
||||
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+152
-10
@@ -2,11 +2,16 @@
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Three kinds live here: sessions and commands, ranked together by default,
|
||||
// and directories, which need the `~` sigil. The action page and the
|
||||
// terminal-focus binding land in #151-#152.
|
||||
// and directories, which need the `~` sigil. `tab` on a session drills into
|
||||
// its action page (#151), which is the same overlay over three actions.
|
||||
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
@@ -30,7 +35,13 @@ export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
|
||||
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||
* single `label` field, ranked exactly like a registry command — and carries
|
||||
* the action it stands for instead of a registry entry. */
|
||||
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||
|
||||
export type BarCandidate =
|
||||
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||
@@ -73,6 +84,40 @@ export function parseBarQuery(raw: string): BarQuery {
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- the session action page ----
|
||||
|
||||
/** The actions a session's action page offers, and no others: the bar picks
|
||||
* one and hands off to the flow that already implements it
|
||||
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||
|
||||
export interface SessionAction {
|
||||
id: SessionActionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||
{ id: "kill", label: "Kill session" },
|
||||
{ id: "rename", label: "Rename session" },
|
||||
{ id: "spawn-similar", label: "Spawn similar" },
|
||||
];
|
||||
|
||||
/** The action page's candidates, in the order above — which is the order an
|
||||
* empty query lists them in, since the matcher keeps input order. */
|
||||
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||
return SESSION_ACTIONS.map((action) => ({
|
||||
kind: "command",
|
||||
action,
|
||||
fields: { label: action.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||
* `command` kind, so what separates them is what they carry. */
|
||||
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||
return candidate.kind === "command" && "action" in candidate;
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
@@ -306,6 +351,8 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
if (candidate.kind === "session") {
|
||||
row.dataset.sessionId = candidate.session.id;
|
||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
} else if (isActionCandidate(candidate)) {
|
||||
row.dataset.actionId = candidate.action.id;
|
||||
} else if (candidate.kind === "command") {
|
||||
row.dataset.commandId = candidate.command.id;
|
||||
} else {
|
||||
@@ -322,7 +369,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
|
||||
// The chord is teaching material, not a match target: it is rendered from
|
||||
// the live registry entry and never fed to the matcher.
|
||||
if (candidate.kind === "command") {
|
||||
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
|
||||
const keys = document.createElement("span");
|
||||
keys.className = "cmdbar-row-keys";
|
||||
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||
@@ -374,6 +421,13 @@ export interface CommandBarOptions {
|
||||
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||
* spawn. */
|
||||
onSpawnDirectory?: (path: string) => void;
|
||||
/** The action page's three actions (#151). Each defaults to raising the
|
||||
* event the flow that already implements it listens on — the sidebar's
|
||||
* confirm-and-blockers kill, its inline rename prompt, the spawn form
|
||||
* seeded from the session. The bar picks the action and nothing more. */
|
||||
onKillSession?: (session: Session) => void;
|
||||
onRenameSession?: (session: Session) => void;
|
||||
onSpawnSimilar?: (session: Session) => void;
|
||||
}
|
||||
|
||||
interface OpenBar {
|
||||
@@ -406,6 +460,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
opts.onSpawnDirectory ??
|
||||
((path: string) =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||
const sessionEvent =
|
||||
(name: string) =>
|
||||
(session: Session): void => {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail: session }));
|
||||
};
|
||||
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
|
||||
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
|
||||
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
|
||||
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||
const listEntries =
|
||||
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||
@@ -450,6 +512,13 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
let rows: BarResult[] = [];
|
||||
let highlighted = 0;
|
||||
|
||||
// The action page (#151). `page` is the session being acted on, `stashed`
|
||||
// the result-list query it was drilled into from — backing out restores it,
|
||||
// so the round trip costs the operator nothing.
|
||||
let page: Session | null = null;
|
||||
let stashed = "";
|
||||
let header: HTMLElement | null = null;
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
let directories: DirectoryBarCandidate[] = [];
|
||||
@@ -516,6 +585,7 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
@@ -547,9 +617,27 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
/** The line naming the session being acted on, so the target is never
|
||||
* ambiguous. Present only while the action page is up. */
|
||||
function paintHeader(): void {
|
||||
header?.remove();
|
||||
header = null;
|
||||
if (!page) return;
|
||||
header = document.createElement("div");
|
||||
header.className = "cmdbar-page-header";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = `Actions for "${sessionName(page)}"`;
|
||||
panel.insertBefore(header, results);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const parsed = parseBarQuery(input.value);
|
||||
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
|
||||
input.placeholder = page
|
||||
? `Act on ${sessionName(page)}…`
|
||||
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||
paintHeader();
|
||||
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||
// The highlight starts on the globally best row every time the list
|
||||
// changes: an old index would point at a different result.
|
||||
@@ -567,8 +655,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
// While a sigil is in force there is one kind, so the header is dropped.
|
||||
if (parsed.sigil === null) {
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped — and the action page is one kind too.
|
||||
if (parsed.sigil === null && !page) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
@@ -593,10 +682,50 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
paintHighlight();
|
||||
}
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
function drillIn(): void {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
page = candidate.session;
|
||||
stashed = input.value;
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
function leavePage(): void {
|
||||
page = null;
|
||||
input.value = stashed;
|
||||
stashed = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function runAction(action: SessionAction, session: Session): void {
|
||||
close();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
killSession(session);
|
||||
return;
|
||||
case "rename":
|
||||
renameSession(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
spawnSimilar(session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function activate(index: number): void {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
@@ -615,7 +744,20 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
close();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else close();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && input.value === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1472,6 +1472,15 @@ input.leaf-rename:focus {
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
/* The action page's header (#151): names the session being acted on, so the
|
||||
target of kill/rename/spawn-similar is never ambiguous. */
|
||||
.cmdbar-page-header {
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cmdbar-group-title {
|
||||
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||
font-size: var(--fs-11);
|
||||
|
||||
Reference in New Issue
Block a user