Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b260b5c20 |
+145
-15
@@ -23,7 +23,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::catalog::CatalogStore;
|
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::config::TuiConfig;
|
||||||
use crate::daemon_link::DaemonLink;
|
use crate::daemon_link::DaemonLink;
|
||||||
use crate::draft::{DraftInputResult, DraftPane};
|
use crate::draft::{DraftInputResult, DraftPane};
|
||||||
@@ -479,6 +479,25 @@ impl App {
|
|||||||
self.close_command_bar();
|
self.close_command_bar();
|
||||||
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
|
(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) => {
|
Some(OverlayKind::Help) => {
|
||||||
@@ -555,24 +574,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
Action::KillHighlighted => {
|
Action::KillHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
self.confirm_kill(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::RenameHighlighted => {
|
Action::RenameHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
// Pre-fill with the custom name only: a session that has
|
self.prompt_rename(sid);
|
||||||
// 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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,6 +865,30 @@ impl App {
|
|||||||
Some(None)
|
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
|
/// 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
|
/// project of the session the operator was last on — so `n` then Enter
|
||||||
/// spawns another session there, the way "spawn similar" would. A
|
/// spawns another session there, the way "spawn similar" would. A
|
||||||
@@ -871,6 +902,30 @@ impl App {
|
|||||||
self.state.open_spawn_modal();
|
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
|
/// The project of the session the operator was last on — its base
|
||||||
/// repository for a worktree session, its cwd otherwise. Shared by the
|
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||||
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
/// 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.
|
//! result list, opened from Normal or Insert mode, closed with Escape.
|
||||||
//!
|
//!
|
||||||
//! Three kinds live here: sessions and commands, ranked together by default,
|
//! Three kinds live here: sessions and commands, ranked together by default,
|
||||||
//! and directories, which need the `~` sigil (#154). The session action page
|
//! and directories, which need the `~` sigil (#154). `tab` on a session drills
|
||||||
//! (#155) and the leader's literal Ctrl-K (#156) land later.
|
//! 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
|
//! 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
|
//! 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
|
/// Fast-forward spawn in this directory, absolute — the abbreviated form
|
||||||
/// is what the row shows and what the matcher ranked.
|
/// is what the row shows and what the matcher ranked.
|
||||||
Directory(PathBuf),
|
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
|
/// The first character of the query, when it is one of the two that narrow the
|
||||||
/// list to a single kind.
|
/// list to a single kind.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -255,10 +290,23 @@ pub struct CommandBar {
|
|||||||
sessions: Vec<BarEntry>,
|
sessions: Vec<BarEntry>,
|
||||||
commands: Vec<BarEntry>,
|
commands: Vec<BarEntry>,
|
||||||
directories: 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>,
|
rows: Vec<BarRow>,
|
||||||
highlight: usize,
|
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 {
|
impl CommandBar {
|
||||||
/// Open the bar over the sessions the catalog has right now and the live
|
/// Open the bar over the sessions the catalog has right now and the live
|
||||||
/// keymap for the current mode. Directories arrive later
|
/// keymap for the current mode. Directories arrive later
|
||||||
@@ -293,6 +341,8 @@ impl CommandBar {
|
|||||||
sessions,
|
sessions,
|
||||||
commands,
|
commands,
|
||||||
directories: Vec::new(),
|
directories: Vec::new(),
|
||||||
|
page: None,
|
||||||
|
stashed: String::new(),
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
highlight: 0,
|
highlight: 0,
|
||||||
};
|
};
|
||||||
@@ -381,7 +431,15 @@ impl CommandBar {
|
|||||||
/// "Keys") does not claim is ordinary editing.
|
/// "Keys") does not claim is ordinary editing.
|
||||||
pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome {
|
pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome {
|
||||||
match key {
|
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() {
|
InputKey::Enter => match self.selected() {
|
||||||
Some(row) => BarOutcome::Open(row.target.clone()),
|
Some(row) => BarOutcome::Open(row.target.clone()),
|
||||||
// Nothing to open: the bar stays up rather than closing on a
|
// Nothing to open: the bar stays up rather than closing on a
|
||||||
@@ -402,9 +460,19 @@ impl CommandBar {
|
|||||||
self.move_highlight(1);
|
self.move_highlight(1);
|
||||||
BarOutcome::Stay
|
BarOutcome::Stay
|
||||||
}
|
}
|
||||||
// Tab drills into the highlighted session's action page (#155).
|
// Tab drills into the highlighted session's action page (#155);
|
||||||
// Until then it is inert rather than a literal tab in the query.
|
// shift-tab is inert rather than a literal tab in the query.
|
||||||
InputKey::Tab | InputKey::BackTab => BarOutcome::Stay,
|
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 => {
|
other => {
|
||||||
let before = self.input.value().to_string();
|
let before = self.input.value().to_string();
|
||||||
self.input.handle_key(other);
|
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.
|
/// The sigil in force for the query as it stands.
|
||||||
pub fn sigil(&self) -> Option<BarSigil> {
|
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;
|
/// 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
|
/// with no sigil the list is mixed — sessions and commands ranked together
|
||||||
/// — except on an empty query, where the bar is a session switcher.
|
/// — except on an empty query, where the bar is a session switcher.
|
||||||
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
|
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
|
||||||
|
if let Some(page) = &self.page {
|
||||||
|
return page.actions.iter().collect();
|
||||||
|
}
|
||||||
match parsed.sigil {
|
match parsed.sigil {
|
||||||
Some(BarSigil::Command) => self.commands.iter().collect(),
|
Some(BarSigil::Command) => self.commands.iter().collect(),
|
||||||
Some(BarSigil::Directory) => self.directories.iter().collect(),
|
Some(BarSigil::Directory) => self.directories.iter().collect(),
|
||||||
@@ -438,7 +566,7 @@ impl CommandBar {
|
|||||||
/// different result.
|
/// different result.
|
||||||
fn recompute(&mut self) {
|
fn recompute(&mut self) {
|
||||||
let rows: Vec<BarRow> = {
|
let rows: Vec<BarRow> = {
|
||||||
let parsed = parse_bar_query(self.input.value());
|
let parsed = self.parsed_query();
|
||||||
let entries = self.active_entries(parsed);
|
let entries = self.active_entries(parsed);
|
||||||
let candidates: Vec<MatchCandidate> =
|
let candidates: Vec<MatchCandidate> =
|
||||||
entries.iter().map(|e| e.candidate.clone()).collect();
|
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 height = parent.height.saturating_sub(4).clamp(5, 20);
|
||||||
let area = centered(parent, 72, height);
|
let area = centered(parent, 72, height);
|
||||||
f.render_widget(Clear, area);
|
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()
|
let block = Block::default()
|
||||||
.title(match self.sigil() {
|
.title(title)
|
||||||
Some(BarSigil::Command) => " run a command ",
|
|
||||||
Some(BarSigil::Directory) => " spawn in ",
|
|
||||||
None => " go to ",
|
|
||||||
})
|
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.style(pal.card.base())
|
.style(pal.card.base())
|
||||||
.border_style(pal.border_style(true));
|
.border_style(pal.border_style(true));
|
||||||
@@ -510,7 +642,11 @@ impl CommandBar {
|
|||||||
.render(query, f.buffer_mut(), pal.card.text_style());
|
.render(query, f.buffer_mut(), pal.card.text_style());
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(Span::styled(
|
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(),
|
pal.card.dim_style(),
|
||||||
))),
|
))),
|
||||||
row(inner.height - 1),
|
row(inner.height - 1),
|
||||||
@@ -540,8 +676,9 @@ impl CommandBar {
|
|||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
let mut highlight_line = 0usize;
|
let mut highlight_line = 0usize;
|
||||||
// While a sigil is in force there is one kind, so the header is
|
// While a sigil is in force there is one kind, so the header is
|
||||||
// dropped — it would name what the whole list already is.
|
// dropped — it would name what the whole list already is. The action
|
||||||
let headers = self.sigil().is_none();
|
// page is one kind too.
|
||||||
|
let headers = self.sigil().is_none() && self.page.is_none();
|
||||||
for group in self.groups() {
|
for group in self.groups() {
|
||||||
if headers {
|
if headers {
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
@@ -1219,4 +1356,163 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}");
|
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
|
/// respawns there without touching the picker. Consumed by the first
|
||||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||||
pub seed_cwd: Option<PathBuf>,
|
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),
|
/// 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
|
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||||
/// cached, so a project-list refresh can't leave it stale.
|
/// 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),
|
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
presets,
|
presets,
|
||||||
selected_preset: 0,
|
selected_preset: 0,
|
||||||
|
seed_command: None,
|
||||||
custom_command: crate::text_input::TextInput::default(),
|
custom_command: crate::text_input::TextInput::default(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
filter: crate::text_input::TextInput::default(),
|
filter: crate::text_input::TextInput::default(),
|
||||||
@@ -342,6 +347,27 @@ impl DraftPane {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.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
|
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||||
@@ -1688,6 +1714,48 @@ mod tests {
|
|||||||
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
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]
|
#[test]
|
||||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
Reference in New Issue
Block a user