From 0b90cb9596473ede586f5b764e90b1710c766216 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 22 Aug 2026 11:10:01 +0100 Subject: [PATCH 1/3] feat: the fuzzy selector takes several workspaces for the verbs that can use them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verb that applies per workspace and returns — up, stop, rm, code, dotfiles, and the --rm/--stop spellings — now opens the picker in skim's multi-select: TAB marks any number of rows and Enter applies the verb to each in turn, so dl rm clears five dead workspaces in one visit. Every marked workspace is attempted whatever happened to the ones before it, and the command's ending is the first that was not Done. The forms that end in an interactive session (attach, --, restart, recreate, reset) still take exactly one — several of those would just be sessions queued behind each other's exit. The completion-cache latch is re-armed between workspaces, the same reasoning as --autorm's re-arm: each workspace after the first is one more state change after whatever refresh the last one spawned. --- CHANGELOG.md | 14 ++++ README.md | 9 ++- rust/dl/src/cli.rs | 25 ++++++- rust/dl/src/commands.rs | 66 +++++++++++++---- rust/dl/src/select.rs | 140 +++++++++++++++++++++++++++++-------- rust/dl/tests/read_side.rs | 13 +++- 6 files changed, 217 insertions(+), 50 deletions(-) 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..85969d64 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -200,6 +200,23 @@ 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. + pub(crate) fn several_at_once(&self) -> bool { + matches!( + self, + Verb::Up | Verb::Stop | Verb::Remove { .. } | Verb::Code | Verb::Dotfiles + ) + } + /// The word this verb is spelled with, for a diagnostic that names it. pub(crate) fn word(&self) -> &'static str { match self { @@ -253,7 +270,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 +523,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..ccabec88 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,12 +141,14 @@ 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() }; @@ -138,29 +166,33 @@ 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. +/// 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, 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; - }; - offers - .iter() - .find(|offer| offer.label == row) - .map_or(Pick::Quit, |offer| Pick::Chose(offer.workspace_id.clone())) +/// 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. @@ -238,8 +270,53 @@ 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 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 +364,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 +375,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 ); From e5a539f9f4018d4cb792991681c15ab13c86730c Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 22 Aug 2026 11:28:30 +0100 Subject: [PATCH 2/3] fix: marking a second workspace deselected the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skim keys every marked row by (run, get_index()), and the SkimItem trait's get_index() defaults to 0 — so rows that carry no index of their own all collide on one key, and each TAB after the first removed the previous mark instead of adding to it. Observed live through a pty: mark two workspaces, Enter, and only one is acted on; skim's own sk -m binary returns both because its item type implements the index. Every row now carries its position among the offers as its index, and the test pins that the indices are distinct — the property the accumulation depends on. --- rust/dl/src/select.rs | 65 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/rust/dl/src/select.rs b/rust/dl/src/select.rs index ccabec88..bc37bbc7 100644 --- a/rust/dl/src/select.rs +++ b/rust/dl/src/select.rs @@ -153,10 +153,7 @@ fn run_skim(offers: &[Offer], arity: Arity) -> Vec { ..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() { @@ -179,6 +176,21 @@ fn run_skim(offers: &[Offer], arity: Arity) -> Vec { .collect() } +/// The offers as skim items, each carrying its own position as the item index. +fn rows_of(offers: &[Offer]) -> Vec> { + offers + .iter() + .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 @@ -198,12 +210,26 @@ fn chosen(offers: &[Offer], rows: Vec) -> Pick { /// 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)] @@ -284,6 +310,37 @@ mod tests { 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 From 358c08280726d6e5da569a2ea7dadf2744f6f604 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 22 Aug 2026 11:28:30 +0100 Subject: [PATCH 3/3] fix: a new verb would get single-select by omission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit several_at_once was a matches! over the batching arms, so a verb added to Verb later would silently fall to single-select instead of breaking the build until somebody answers the question. An exhaustive match is this module's own rule for that obligation. Not testable — the failure is a compile that should have happened. --- rust/dl/src/cli.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index 85969d64..401128c7 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -210,11 +210,15 @@ impl Verb { /// 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 { - matches!( - self, - Verb::Up | Verb::Stop | Verb::Remove { .. } | Verb::Code | Verb::Dotfiles - ) + 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.