Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command>`, `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
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command>`, `restart`, `recreate`, `reset`) take exactly one, since several of those
would just be sessions queued behind each other's exit.

### Examples

```bash
Expand Down Expand Up @@ -129,7 +135,8 @@ transport, which has no terminal; `dl <ws> restart` republishes the alias. Set
| `dl <user/repo> --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 <user/repo>` — 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
Expand Down
29 changes: 27 additions & 2 deletions rust/dl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<DevcontainerPath>,
Expand Down Expand Up @@ -505,7 +527,10 @@ Workspace commands (dl <workspace> <verb>, or dl <verb> <workspace>):
dotfiles Refresh dotfiles (chezmoi update)
-- <command> 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:
Expand Down
66 changes: 52 additions & 14 deletions rust/dl/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,14 +1062,24 @@
// 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 <ws> <verb>` 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>(
Expand All @@ -1084,21 +1094,49 @@
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() {

Check warning on line 1116 in rust/dl/src/commands.rs

View check run for this annotation

Codecov / codecov/patch

rust/dl/src/commands.rs#L1114-L1116

Added lines #L1114 - L1116 were not covered by tests
// 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,

Check warning on line 1132 in rust/dl/src/commands.rs

View check run for this annotation

Codecov / codecov/patch

rust/dl/src/commands.rs#L1122-L1132

Added lines #L1122 - L1132 were not covered by tests
);
if matches!(ending, Ending::Done) {
ending = ran;
}

Check warning on line 1136 in rust/dl/src/commands.rs

View check run for this annotation

Codecov / codecov/patch

rust/dl/src/commands.rs#L1134-L1136

Added lines #L1134 - L1136 were not covered by tests
}
ending

Check warning on line 1138 in rust/dl/src/commands.rs

View check run for this annotation

Codecov / codecov/patch

rust/dl/src/commands.rs#L1138

Added line #L1138 was not covered by tests
}
select::Pick::NoWorkspaces => {
eprintln!("No workspaces found. Create one with: dl owner/repo or dl ./path");
no_pick()
Expand Down
Loading