diff --git a/CHANGELOG.md b/CHANGELOG.md index f90ac84f..22aeeb86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **The fuzzy selector takes several workspaces for the verbs that can use them.** + `dl rm`, `dl stop`, `dl up`, `dl code` and `dl dotfiles` (and the `--rm`/`--stop` + spellings) with no workspace named now open the picker in multi-select: TAB marks + any number of rows, Enter applies the verb to each in turn, so five dead + workspaces are cleared in one visit instead of five. Every marked workspace is + attempted whatever happened to the ones before it — one `rm` refused over unsaved + work does not drop the rest of the batch — and the exit code is the first + failure's, so scripts still learn something went wrong. The forms that end in an + interactive session (`dl`, `dl -- `, `restart`, `recreate`, `reset`) + still take exactly one, since several of those would just be sessions queued + behind each other's exit. + ## [0.5.0] - 2026-08-21 ### Fixed diff --git a/README.md b/README.md index 1e496d34..1d2a095f 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ install for it and why `dl` with its input redirected away from a terminal simpl one. A reserved verb wins over a workspace name of the same spelling: `dl stop` opens the selector to stop something, it does not look for a workspace called `stop`. +For the verbs that finish on their own — `up`, `stop`, `rm`, `code` and `dotfiles` — the selector +takes more than one row: TAB marks any number and Enter applies the verb to each in turn, so +`dl rm` can clear five dead workspaces in one visit. The forms that end in an interactive session +(`dl`, `dl -- `, `restart`, `recreate`, `reset`) take exactly one, since several of those +would just be sessions queued behind each other's exit. + ### Examples ```bash @@ -129,7 +135,8 @@ transport, which has no terminal; `dl restart` republishes the alias. Set | `dl --autorm` | Attach, and [delete the workspace when the session ends](#--autorm-the-throwaway-workspace) | Every verb in that table also takes the workspace second — `dl stop ` — and with no -workspace at all it opens the selector and applies itself to what you pick. `stop` and `rm` answer to +workspace at all it opens the selector and applies itself to what you pick — everything you pick, +for the verbs the selector lets TAB mark several of. `stop` and `rm` answer to `--stop` and `--rm` as well, since the flag spellings were documented long before they worked. ### `--stop` and `--rm` can be appended to a line that says something else diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index dae3309f..401128c7 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -200,6 +200,27 @@ pub(crate) enum Verb { } impl Verb { + /// Whether the selector may hand this verb several workspaces at once. + /// + /// Yes for the verbs that finish on their own: `up`, `stop`, `rm`, `code` and + /// `dotfiles` apply to each workspace in turn and return, so `dl rm` can mark + /// five dead workspaces and clear them in one visit — the same TAB-to-mark + /// batch `fzf --multi` taught everyone. No for anything that ends in an + /// interactive session — attach, `--`, and the three rebuild verbs, whose + /// launch attaches when it is done (`LaunchVerb::attaches`): several of those + /// would be sessions run back to back, each waiting on the last one's exit, + /// which is a queue nobody asked the picker for. + /// Exhaustive rather than a `matches!` with a default, so a new verb does not + /// get single-select by omission: whoever adds the arm answers the question. + pub(crate) fn several_at_once(&self) -> bool { + match self { + Verb::Up | Verb::Stop | Verb::Remove { .. } | Verb::Code | Verb::Dotfiles => true, + Verb::Attach { .. } | Verb::Run(..) | Verb::Recreate | Verb::Restart | Verb::Reset => { + false + } + } + } + /// The word this verb is spelled with, for a diagnostic that names it. pub(crate) fn word(&self) -> &'static str { match self { @@ -253,7 +274,8 @@ pub(crate) enum Command { Reconcile { yes: bool }, /// `dl --purge [-y]` Purge { yes: bool }, - /// A verb with no workspace named: the fuzzy selector picks one (M8). + /// A verb with no workspace named: the fuzzy selector picks one (M8) — or, + /// for a verb that applies per workspace ([`Verb::several_at_once`]), several. Select { verb: Verb, devcontainer: Option, @@ -505,7 +527,10 @@ Workspace commands (dl , or dl ): dotfiles Refresh dotfiles (chezmoi update) -- Run one command inside it -A verb with no workspace named picks one interactively. +A verb with no workspace named picks interactively. For up, stop, rm, code and +dotfiles, TAB marks several rows and the verb applies to each in turn — dl rm can +clear five workspaces in one visit. The forms that end in a session (attach, --, +restart, recreate, reset) take exactly one. --stop and --rm are the same two verbs as flags, and unlike the words they may be appended to a line that already says something else, which then loses: diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index ca37a583..fcfec343 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -1062,14 +1062,24 @@ fn render_reconcile( // the selector // --------------------------------------------------------------------------- -/// A verb with no workspace named: the embedded fuzzy picker chooses one. +/// A verb with no workspace named: the embedded fuzzy picker chooses one — or, +/// for a verb that applies per workspace, several. /// -/// **Divergence row 21** decides where the pick goes: through the same path +/// **Divergence row 21** decides where each pick goes: through the same path /// `dl ` takes, rather than Python's straight-to-`workspace_up`. One /// `devpod status` buys the fast attach every other entry already pays for, and the /// verb the selector was opened with is honoured — `dl --stop` picks a workspace and /// stops it. /// +/// Whether the picker takes one row or many is the verb's to say +/// ([`Verb::several_at_once`]): `dl rm` lets TAB mark five dead workspaces and +/// clears them in one visit, while a verb that ends in a session takes one. A batch +/// is applied in the order the rows were taken, every workspace attempted whatever +/// happened to the ones before it — the point of marking five is that one refusal +/// (say, unsaved work) must not silently drop the other four. The command's ending +/// is the first that was not [`Ending::Done`], so a script still learns something +/// failed and the specific code of the first failure survives. +/// /// A pick that never came is Python's ending exactly: the help on stdout and exit 1 /// (`dl.py` 4457-4462). The help is clap's (**row 3**). fn render_select<'r>( @@ -1084,21 +1094,49 @@ fn render_select<'r>( Err(refused) => return refuse_listing(&refused), Ok(workspaces) => workspaces, }; + let arity = if verb.several_at_once() { + select::Arity::Several + } else { + select::Arity::One + }; // Said before the picker takes the screen, as Python says it: it is the only - // thing that explains what the rows are. + // thing that explains what the rows are — and, for a verb that takes several, + // the only place TAB is discoverable. if !workspaces.is_empty() { - println!("Select workspace (type to filter):"); + match arity { + select::Arity::One => println!("Select workspace (type to filter):"), + select::Arity::Several => { + println!("Select workspaces (type to filter, TAB to mark several):"); + } + } } - match select::pick(&workspaces) { - select::Pick::Chose(workspace_id) => render_workspace( - runner, - context, - cache, - refresh, - &workspace_id, - verb, - devcontainer, - ), + match select::pick(&workspaces, arity) { + select::Pick::Chose(workspace_ids) => { + let mut ending = Ending::Done; + for (already_acted, workspace_id) in workspace_ids.iter().enumerate() { + // Each workspace after the first is one more state change after + // whatever refresh the last one spawned, so the child indexing the + // old world must not be the last word — the same reasoning as + // `--autorm`'s re-arm in `after_the_session`. A no-op for the + // single pick every verb used to be. + if already_acted > 0 { + refresh.rearm(); + } + let ran = render_workspace( + runner, + context, + cache, + refresh, + workspace_id, + verb.clone(), + devcontainer, + ); + if matches!(ending, Ending::Done) { + ending = ran; + } + } + ending + } select::Pick::NoWorkspaces => { eprintln!("No workspaces found. Create one with: dl owner/repo or dl ./path"); no_pick() diff --git a/rust/dl/src/select.rs b/rust/dl/src/select.rs index b72a2955..bc37bbc7 100644 --- a/rust/dl/src/select.rs +++ b/rust/dl/src/select.rs @@ -13,6 +13,13 @@ //! is *offered* rather than quietly dropped from the list //! (`test/unit/test_workspace_source.py::TestTheFuzzyPickerOffersEverySource`). //! +//! **One deliberate departure from Python's picker: it can take several rows.** +//! Python's `iterfzf(..., multi=False)` answered one workspace always. Here the +//! verb the selector was opened for decides ([`Arity`]): a verb that applies per +//! workspace and returns — `up`, `stop`, `rm`, `code`, `dotfiles` — lets TAB mark +//! any number of rows, so `dl rm` can clear five dead workspaces in one visit, +//! while the forms that end in a session still take exactly one. +//! //! [`offered`] is that list and nothing else — a pure function of what devpod said, //! which is what makes the spec testable without a terminal. [`pick`] is the //! interactive half. @@ -21,6 +28,7 @@ use std::borrow::Cow; use std::sync::Arc; use devlaunch_core::clients::devpod::Workspace; +use devlaunch_core::domain::workspace_state::NonEmpty; use devlaunch_core::flows::listing::describe_source; use skim::prelude::*; @@ -57,6 +65,21 @@ pub(crate) fn offered(workspaces: &[Workspace]) -> Vec { .collect() } +/// How many rows one picker run may take. +/// +/// Decided by the verb the selector was opened for +/// ([`Verb::several_at_once`](crate::cli::Verb::several_at_once)), not by the +/// picker: skim will happily multi-select for anything, and the limit is about +/// what the verb can do with the answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Arity { + /// Enter takes the row under the cursor and nothing else. + One, + /// TAB marks any number of rows; Enter takes the marked set, or the row under + /// the cursor when none are marked. + Several, +} + /// What the picker settled. /// /// Four arms where Python has `Optional[str]`, because its `None` covers four @@ -66,9 +89,11 @@ pub(crate) fn offered(workspaces: &[Workspace]) -> Vec { /// help and exits 1 — but which one happened is the caller's to say. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum Pick { - /// This workspace, by id. - Chose(String), - /// The picker was opened and closed without a choice: Esc, Ctrl-C, or a row + /// These workspaces, by id, in the order skim handed the rows back. One entry + /// always under [`Arity::One`]; [`NonEmpty`] because an empty set of choices + /// is [`Pick::Quit`], not a batch of nothing. + Chose(NonEmpty), + /// The picker was opened and closed without a choice: Esc, Ctrl-C, or rows /// that named no workspace. Quit, /// devpod lists nothing, so there is nothing to offer. @@ -79,8 +104,9 @@ pub(crate) enum Pick { NoTerminal, } -/// Offer these workspaces and wait for one to be chosen. -pub(crate) fn pick(workspaces: &[Workspace]) -> Pick { +/// Offer these workspaces and wait for one — or, under [`Arity::Several`], any +/// number — to be chosen. +pub(crate) fn pick(workspaces: &[Workspace], arity: Arity) -> Pick { let offers = offered(workspaces); if offers.is_empty() { return Pick::NoWorkspaces; @@ -91,7 +117,7 @@ pub(crate) fn pick(workspaces: &[Workspace]) -> Pick { if !a_terminal_exists() { return Pick::NoTerminal; } - chosen(&offers, run_skim(&offers)) + chosen(&offers, run_skim(&offers, arity)) } /// Whether this run has a terminal at all. @@ -115,20 +141,19 @@ fn a_terminal_exists() -> bool { } } -/// The row skim was left on, if it was left on one. -fn run_skim(offers: &[Offer]) -> Option { +/// The rows skim was left on: empty when the picker was quit without an answer. +fn run_skim(offers: &[Offer], arity: Arity) -> Vec { let options = SkimOptions { - // `iterfzf(options, multi=False)`'s defaults: one pick, and the input order - // preserved rather than re-sorted (Python's `sort=False` -> `--no-sort`). - multi: false, + // skim's `--multi` when the verb takes several — TAB toggles a row, as it + // does in fzf — and `iterfzf(options, multi=False)`'s one pick otherwise. + // Input order preserved rather than re-sorted either way (Python's + // `sort=False` -> `--no-sort`). + multi: matches!(arity, Arity::Several), no_sort: true, ..Default::default() }; let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded(); - for offer in offers { - let row: Arc = Arc::new(Row { - label: offer.label.clone(), - }); + for row in rows_of(offers) { // A send that fails means the reader is gone, which is a picker that is not // going to answer; the remaining rows are not worth a diagnostic. if tx.send(row).is_err() { @@ -138,40 +163,73 @@ fn run_skim(offers: &[Offer]) -> Option { // The reader stops at the end of the stream, and the stream ends when the last // sender is dropped. drop(tx); - let output = Skim::run_with(&options, Some(rx))?; + let Some(output) = Skim::run_with(&options, Some(rx)) else { + return Vec::new(); + }; if output.is_abort { - return None; + return Vec::new(); } output .selected_items - .first() + .iter() .map(|item| item.output().into_owned()) + .collect() } -/// Which workspace a chosen row names. -/// -/// Python looks the label up in its `ws_map` and answers `None` when it is not -/// there; the same lookup is here, and a row naming no workspace reads as no -/// choice rather than as a workspace called something else. -fn chosen(offers: &[Offer], row: Option) -> Pick { - let Some(row) = row else { - return Pick::Quit; - }; +/// The offers as skim items, each carrying its own position as the item index. +fn rows_of(offers: &[Offer]) -> Vec> { offers .iter() - .find(|offer| offer.label == row) - .map_or(Pick::Quit, |offer| Pick::Chose(offer.workspace_id.clone())) + .enumerate() + .map(|(index, offer)| { + let row: Arc = Arc::new(Row { + label: offer.label.clone(), + index, + }); + row + }) + .collect() +} + +/// Which workspaces the chosen rows name. +/// +/// Python looks the label up in its `ws_map` and answers `None` when it is not +/// there; the same lookup is here, per row. A row naming no workspace is dropped +/// rather than read as a workspace called something else, and rows naming nothing +/// at all — none chosen, or none that map back — read as no choice. +fn chosen(offers: &[Offer], rows: Vec) -> Pick { + let picked = rows.iter().filter_map(|row| { + offers + .iter() + .find(|offer| offer.label == *row) + .map(|offer| offer.workspace_id.clone()) + }); + NonEmpty::of(picked).map_or(Pick::Quit, Pick::Chose) } /// One offered row, as skim reads it. struct Row { label: String, + /// The row's position among the offers. skim's multi-select keys every marked + /// row by `(run, get_index())`, and `get_index()` defaults to 0 — so rows that + /// do not carry their own index all collide on one key, and each TAB *removes* + /// the previous mark instead of adding to it. One distinct index per row is + /// what makes marking accumulate. + index: usize, } impl SkimItem for Row { fn text(&self) -> Cow<'_, str> { Cow::Borrowed(&self.label) } + + fn get_index(&self) -> usize { + self.index + } + + fn set_index(&mut self, index: usize) { + self.index = index; + } } #[cfg(test)] @@ -238,8 +296,84 @@ mod tests { // Picking the row maps back to the workspace, which is what makes it an // offer rather than a line of text. assert_eq!( - chosen(&offers, Some(offers[1].label.clone())), - Pick::Chose("from-an-image".to_owned()) + chosen(&offers, vec![offers[1].label.clone()]), + Pick::Chose(one_id("from-an-image")) + ); + } + + /// A `Pick::Chose` of exactly these ids, for the assertions below. + fn ids(named: &[&str]) -> Pick { + Pick::Chose(NonEmpty::of(named.iter().map(|id| (*id).to_owned())).expect("at least one id")) + } + + fn one_id(named: &str) -> NonEmpty { + NonEmpty::of([named.to_owned()]).expect("one id") + } + + #[test] + fn every_row_carries_its_own_index_or_marking_cannot_accumulate() { + // skim's multi-select keys each marked row by `(run, get_index())`, and the + // trait's `get_index()` defaults to 0. Rows all answering 0 therefore share + // one key, and every TAB after the first *removes* the previous mark + // instead of adding to it — observed live: mark two workspaces, and only + // the last one is acted on. Distinct indices are what make marking + // accumulate, so they are the spec. + let offers = offered(&listed( + r#"[ + {"id": "first", "source": {"localFolder": "/a"}, "lastUsed": "x", + "provider": {"name": "docker"}, "ide": {"name": "none"}, + "context": "default"}, + {"id": "second", "source": {"localFolder": "/b"}, "lastUsed": "x", + "provider": {"name": "docker"}, "ide": {"name": "none"}, + "context": "default"}, + {"id": "third", "source": {"localFolder": "/c"}, "lastUsed": "x", + "provider": {"name": "docker"}, "ide": {"name": "none"}, + "context": "default"} + ]"#, + )); + + assert_eq!( + rows_of(&offers) + .iter() + .map(|row| row.get_index()) + .collect::>(), + [0, 1, 2] + ); + } + + #[test] + fn several_rows_map_to_several_workspaces_in_the_order_taken() { + // The multi pick: every chosen row maps back, in the order the rows came + // back, so `dl rm` applies to the workspaces in the order they were marked. + let offers = offered(&listed( + r#"[ + {"id": "first", "source": {"localFolder": "/a"}, "lastUsed": "x", + "provider": {"name": "docker"}, "ide": {"name": "none"}, + "context": "default"}, + {"id": "second", "source": {"localFolder": "/b"}, "lastUsed": "x", + "provider": {"name": "docker"}, "ide": {"name": "none"}, + "context": "default"}, + {"id": "third", "source": {"localFolder": "/c"}, "lastUsed": "x", + "provider": {"name": "docker"}, "ide": {"name": "none"}, + "context": "default"} + ]"#, + )); + + assert_eq!( + chosen( + &offers, + vec![offers[2].label.clone(), offers[0].label.clone()] + ), + ids(&["third", "first"]) + ); + // A row naming no workspace is dropped rather than sinking the rows that + // do name one — the batch the user marked still happens. + assert_eq!( + chosen( + &offers, + vec!["something else".to_owned(), offers[1].label.clone()] + ), + ids(&["second"]) ); } @@ -287,8 +421,9 @@ mod tests { assert!(offered(&none).is_empty()); // And no terminal is opened to say so: nothing to pick from is answered - // before anything is drawn. - assert_eq!(pick(&none), Pick::NoWorkspaces); + // before anything is drawn, whichever arity asked. + assert_eq!(pick(&none, Arity::One), Pick::NoWorkspaces); + assert_eq!(pick(&none, Arity::Several), Pick::NoWorkspaces); } #[test] @@ -297,9 +432,9 @@ mod tests { // `None`, and `None` is the help and exit 1. let offers = offered(&listed(&one("mine", r#"{"localFolder": "/p"}"#))); - assert_eq!(chosen(&offers, None), Pick::Quit); + assert_eq!(chosen(&offers, Vec::new()), Pick::Quit); assert_eq!( - chosen(&offers, Some("something else".to_owned())), + chosen(&offers, vec!["something else".to_owned()]), Pick::Quit ); } diff --git a/rust/dl/tests/read_side.rs b/rust/dl/tests/read_side.rs index 3dfc8851..8fa2c5ff 100644 --- a/rust/dl/tests/read_side.rs +++ b/rust/dl/tests/read_side.rs @@ -803,12 +803,21 @@ fn a_verb_with_no_workspace_opens_the_selector_and_no_terminal_picks_nothing() { // draw no picker at all, so nothing is picked. Python's ending for a pick that // never came is this one: the help on stdout, exit 1 — and the help is clap's // (row 3). + // The invitation names what the picker will take: `stop` and `--rm` apply per + // workspace, so TAB may mark several; a `-- ` ends in one session, so the + // line is the single pick's — and the only place TAB is discoverable is here. let world = World::full(); - for args in [vec!["stop"], vec!["--rm"], vec!["--", "make", "test"]] { + let several = "Select workspaces (type to filter, TAB to mark several):\n"; + let one = "Select workspace (type to filter):\n"; + for (args, invitation) in [ + (vec!["stop"], several), + (vec!["--rm"], several), + (vec!["--", "make", "test"], one), + ] { let run = world.dl(&args); run.exited(1); assert!( - run.out.starts_with("Select workspace (type to filter):\n"), + run.out.starts_with(invitation), "dl {args:?} did not open the picker: {:?}", run.out );