refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
//! Interactive branch/worktree selector.
|
|
|
|
|
|
//!
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//! A skim-based TUI for selecting and switching between worktrees. The picker
|
|
|
|
|
|
//! shares `super::list::collect::collect` with `wt list` — see
|
|
|
|
|
|
//! `commands/list/collect/mod.rs` for the rendering-pipeline spec — but inverts
|
|
|
|
|
|
//! the ordering because skim's `preview_window` height is baked into
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
//! `SkimOptions` before skim takes over the terminal, so we have
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//! to estimate the visible row count up front rather than learn it from
|
|
|
|
|
|
//! collect's skeleton pass.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! # "Skeleton"
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! Same meaning as in `wt list`: the column/row frame with placeholder cells
|
|
|
|
|
|
//! the user sees first. In the picker, `collect::collect` builds those rows
|
|
|
|
|
|
//! and streams them via `on_skeleton` → `PickerHandler` → `SkimItemSender` →
|
|
|
|
|
|
//! skim. (Not to be confused with the rendered skeleton-row *strings* that
|
|
|
|
|
|
//! flow through that channel.)
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! # Startup flow
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! On the main thread, `handle_picker`:
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! 1. `current_or_recover` + config resolution.
|
refactor(picker): read terminal size once for layout sizing (#3210)
## What
Collapses the interactive picker's repeated `terminal_size()` reads into
a single read, threaded explicitly through the layout-sizing code.
## Why
PR #3205 made the picker's Down-layout list height adapt to the
terminal, but left the startup path reading the terminal size 3–4 times
per launch — once in `auto_detect` (layout), once for the
`num_items_estimate` cap, once each inside `to_preview_window_spec` and
`preview_dimensions`, once for the speculative pre-compute, and once for
`half_page`. `to_preview_window_spec` re-read the terminal and
recomputed the Down spec internally, so the Down preview dimensions were
computed twice. Beyond the redundant syscalls, the estimate cap and the
actual layout could observe different terminal sizes if the window was
resized mid-startup — a benign but real race.
## How
`handle_picker` now reads `terminal_size::terminal_size()` once and
threads `(term_width, term_height)` into every sizing site: layout
detection (`PreviewLayout::for_dimensions`), the visible-row cap
(`max_visible_items(available_height(term_height))`), the preview
dimensions (`dimensions_for`), the speculative pre-compute, and the
half-page scroll. `dimensions_for` — already pure and unit-tested — is
the single entry; `spec_for` formats the skim preview-window spec from
the already-computed dims rather than recomputing them.
This retires three terminal-reading methods on `PreviewLayout`:
`auto_detect` (folded into the single read + `for_dimensions`),
`preview_dimensions` (the live-terminal reader), and
`to_preview_window_spec` (which re-read and recomputed). `preview.rs` no
longer reads the terminal at all — the read lives solely in
`handle_picker`. `crate::display::terminal_width()` (a separate
stderr-first width probe for the skim list column) is left as-is; it
isn't part of the layout-sizing path.
## Behavior
No user-facing change. Fallbacks are preserved at every site — the
single read falls back to `(80, 24)`, matching the prior per-call
fallbacks, and `half_page` on that fallback still evaluates to `10`
(`(available_height(24) / 2).max(5)` = `(21 / 2).max(5)` = `10`),
identical to the old `.unwrap_or(10)`. The pre-existing `dimensions_for`
scenario/edge tests pass unchanged; the one spec-formatting test was
retargeted at `spec_for` with exact-string assertions (strictly
stronger), and a redundant duplicate of it in `mod.rs` was removed.
`cargo run -- hook pre-merge --yes` is green (4181 tests, clippy, fmt).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:29:21 -07:00
|
|
|
|
//! 2. Reads the terminal size once; `PreviewState::new` records the
|
|
|
|
|
|
//! Right-vs-Down layout detected from it. Every later sizing step (the
|
|
|
|
|
|
//! estimate cap, preview dimensions, half-page scroll) reuses that snapshot.
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//! 3. Allocates the `PreviewOrchestrator` and kicks off a *speculative*
|
fix(picker): stop first-keystroke freeze (#3087)
Fixes a multi-second freeze in the `wt switch` picker: with many
accumulated worktrees, typing the first character locks the UI for
seconds, then it recovers. The freeze scales with worktree count.
The picker (skim) runs its per-keystroke fuzzy matcher and result sort
on rayon's **global** thread pool. Worktrunk's collection floods that
same global pool with blocking git subprocess tasks (status, diff,
rev-list, merge-base, plus the preview orchestrator's per-mode `git
diff` / `git log`), one batch per worktree. The global pool has only `2×
CPU` workers, and each git call blocks its worker for the subprocess
lifetime. When the user types, skim's matcher queues behind that flood
and can't run until workers drain. `wt list` never froze because nothing
else contends for the pool there.
The fix moves the git-heavy collection and preview work onto a dedicated
`COLLECT_POOL`, leaving the global pool free for skim. This is the same
isolation pattern already used by `copy::COPY_POOL` and
`remove_dir::REMOVE_POOL`. The new pool is sized like the global pool
(`2× CPU`, honoring `RAYON_NUM_THREADS`), so collection throughput is
unchanged. Its only job is to keep the git work off the pool skim's
matcher uses.
## Decisions
- Collection's row pipeline and the preview orchestrator stay on the
same dedicated pool, preserving the orchestrator's intentional "one
shared pool, let workers prefer dominant pressure" design. They just
move off the pool skim needs.
- The bounded pre- and post-skeleton `rayon::scope` calls stay on the
global pool. They are O(1) in worktree count (~7 spawns), so they are
not the scaling flood.
- The single-item statusline path (`populate_item`) routes through
`COLLECT_POOL` too, purely for consistency. A single item never floods
the pool, so this path was never the problem.
- The nested log-refresh `rayon::spawn_fifo` needs no change. The free
`rayon::spawn_fifo` resolves its target via the current worker's
registry, so when called from inside a `COLLECT_POOL` worker it inherits
`COLLECT_POOL` rather than falling back to the global pool. Confirmed
against the rayon-core source.
## Testing
Ran locally, before and after below, depicting the freeze/fix
### Before
https://github.com/user-attachments/assets/55c12cec-4466-487a-b480-fd2ff03ad111
### After
https://github.com/user-attachments/assets/71a5499a-5521-4727-a6b4-ab6a12f317d5
2026-06-15 19:43:45 -07:00
|
|
|
|
//! `git diff HEAD` for the current worktree on `COLLECT_POOL`.
|
2026-05-09 23:43:07 -07:00
|
|
|
|
//! That bg work overlaps with everything below.
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//! 4. Computes `num_items_estimate` — `list_worktrees` plus (conditionally)
|
feat(switch): scale picker list height to the terminal (#3205)
The interactive picker's Down layout (preview below the list) capped the
worktree list at a fixed 12 rows. On a tall terminal with many worktrees
that meant seeing only 12 — with all the surplus height going to a
near-empty preview pane — and the list never adapted to the space
available.
This replaces the `MAX_VISIBLE_ITEMS = 12` constant with
`max_visible_items(available)`, a balanced 50/50 split: the list may
claim up to half of skim's area (`available / 2`) and the preview keeps
the other half, so visible rows scale with terminal height. Integer
division truncates the list's half toward the preview — a deliberate
preview-favoring tie-break — and a `MIN_VISIBLE_ITEMS = 3` floor keeps
the list usable on a short terminal.
The tradeoff is at the common 80×24: it now shows ~6 rows / 11 preview
lines instead of the old 12 rows / 5-line (floor-crushed) preview — a
more balanced split. On a 50-row terminal it shows up to 18 rows; on a
120-row terminal up to 50.
### Navigating the diff
- `src/commands/picker/preview.rs` — the policy. `available_height()` is
the single home for skim's 90%-of-terminal conversion (both layout arms,
the estimate cap, and the half-page scroll all derive from it, retiring
a duplicated magic `45`). `max_visible_items()` is the cap;
`dimensions_for()` is a pure seam extracted from `preview_dimensions()`
so the split is unit-testable without a TTY.
- `src/commands/picker/mod.rs` — the `num_items_estimate` perf
short-circuit now gates on the same height-derived cap, and `half_page`
routes through `available_height()`.
- Right layout is untouched — it already used the full height and
ignores the item count.
### Testing
Six unit tests in `preview.rs` pin the full scenario grid (6 terminal
heights × 4 item counts), the cap table, the no-phantom-rows-when-empty
case, no-panic on degenerate terminals, and the saturation invariant
that keeps the estimate short-circuit sound.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:58:59 -07:00
|
|
|
|
//! `local_branches` / `remote_branches`, capped at the Down layout's
|
|
|
|
|
|
//! `max_visible_items(available)`. Only used to size skim's `preview_window`.
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//! 5. Builds `SkimOptions` (immutable after this — which is why steps 1-4 have
|
|
|
|
|
|
//! to run first).
|
|
|
|
|
|
//! 6. Spawns the `picker-collect` bg thread, which calls `collect::collect`.
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
//! 7. Calls `run_skim(rx)` (a thin wrapper over skim's `init`/`run` that also
|
|
|
|
|
|
//! hands the collect handler skim's event sender for progressive repaints);
|
|
|
|
|
|
//! skim paints the empty frame and then ingests skeleton rows from the
|
|
|
|
|
|
//! channel as the bg thread streams them via `on_skeleton`.
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//!
|
|
|
|
|
|
//! Time-to-skeleton = steps 1-6 on the main thread *plus* collect's
|
docs(list): document pre-skeleton fork inventory (#2883)
Consolidates the spec for what runs before the `wt switch` / `wt list`
skeleton appears, since the previous "Fixed Command Count" table mixed
logical fetches with actual forks (most of the listed commands are now
cache hits served from the prewarmed in-memory config map) and didn't
break down the `git log --no-walk` batch.
Two doc-only edits, no behavior change:
- `commands/list/collect/mod.rs` — replaced `### Fixed Command Count`
with `### Forks on the Critical Path`: five git forks on the path to the
skeleton for a normal repo (six in repos with
`extensions.worktreeConfig=true`, where `Repository::all_config`
re-forks from `git_common_dir` because `--list` from a linked worktree
misses main-worktree `config.worktree` overrides). Each row carries its
source method and role; #3 is explicitly tagged Conditional with the
regime spelled out. A "looks like a fork but isn't" list enumerates the
`config_last` lookups served from the in-memory map. A callout flags
`default_branch()`'s one-per-repo `git ls-remote` fallback (worktrunk's
one accepted wire-path exception per CLAUDE.md → "Network Access"). A
new `### #6 — the batched commit-details fork` subsection breaks down
`git log --no-walk --no-show-signature --format=%H%x00%h%x00%ct%x00%s`
field by field: which downstream cell each format field feeds, why each
flag is there, the ARG_MAX bound on argv length.
- `commands/picker/mod.rs` — a one-sentence pointer onto the existing
"Time-to-skeleton" line directing readers to the new collect section.
Verified locally with `RUSTDOCFLAGS='-Dwarnings' cargo doc --no-deps
--document-private-items` (intra-doc links to `Repository::prewarm`,
`at`, `all_config`, `list_worktrees`, `local_branches`, `url_template`,
`is_bare`, `default_branch` all resolve).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:29:36 -07:00
|
|
|
|
//! pre-skeleton phase on the bg thread. See `commands/list/collect/mod.rs`
|
|
|
|
|
|
//! § "Forks on the Critical Path" for the subprocess inventory (five
|
|
|
|
|
|
//! forks, plus one more in `extensions.worktreeConfig` repos).
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//!
|
|
|
|
|
|
//! ## Phase timings
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! Representative medians on the worktrunk dev repo (7 worktrees, 6 branches,
|
|
|
|
|
|
//! warm caches, release build).
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! | Phase (instant-to-instant) | median | cmds |
|
|
|
|
|
|
//! |-----------------------------|-------:|-----:|
|
|
|
|
|
|
//! | `Picker started → Picker config resolved` | ~16ms | 3 |
|
|
|
|
|
|
//! | `Picker config resolved → Picker layout detected` | <1ms | 0 |
|
|
|
|
|
|
//! | `Picker layout detected → Picker estimate computed` | ~39ms | 11 (includes bg preview `git diff`s) |
|
|
|
|
|
|
//! | `Picker estimate computed → Picker skim options built` | <1ms | 0 |
|
|
|
|
|
|
//! | `Picker skim options built → Picker collect spawned` | <100µs | 0 |
|
|
|
|
|
|
//! | `Picker collect spawned → List collect started` | <100µs | 0 |
|
|
|
|
|
|
//! | `List collect started → Skeleton rendered` (bg, pre-skeleton) | ~41ms | 25 |
|
|
|
|
|
|
//! | **Time-to-skeleton** (≈ main-thread prelude + bg pre-skeleton) | **~96ms** | |
|
|
|
|
|
|
//! | `Skeleton rendered → Spawning worker thread` (post-skeleton, pre-work) | ~156ms | 86 |
|
|
|
|
|
|
//! | `Parallel execution started → All results drained` (post-skeleton work) | ~1.1s | 254 |
|
|
|
|
|
|
//! | Wall clock under `WORKTRUNK_PICKER_DRY_RUN=1` (median / p95) | ~1.4s / ~4.4s | |
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! Skim's own paint cost isn't observable from the dry-run path — skim is
|
|
|
|
|
|
//! bypassed there.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! ### Reproducing
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! End-to-end time-to-first-output (criterion, synthetic repo):
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! ```bash
|
|
|
|
|
|
//! cargo bench --bench time_to_first_output -- switch
|
|
|
|
|
|
//! ```
|
|
|
|
|
|
//!
|
bench: measure `wt switch` picker preview pre-compute workload (#2721)
## Summary
- Adds `picker_preview` benchmark group measuring "process spawn → all
preview tasks drained" for `wt switch`'s interactive picker.
- Introduces `WORKTRUNK_PREVIEW_BENCH=1`, an early-exit gate inside
`handle_picker` that runs the full prelude (collect, speculative spawn,
skeleton, initial + deferred precompute, `orchestrator.wait_for_idle()`)
and returns before skim launches or any JSON / stderr I/O. Shares the
dry-run path; behavior with the env var unset is unchanged.
- Closes the coverage gap behind #2662 / #2683 / #2685 / #2704, which
were tuned against `wt list` as a proxy because no direct picker
measurement existed.
## Why this measurement
Picker submits one preview-compute task per row to the global rayon
pool. The user-visible quantity to optimize is the responsiveness window
between picker launch and "all previews ready" (j/k navigation hits
cached content). Option 1 from the task — headless wall clock to drain —
is the cleanest measurable proxy and avoids the PTY route, which hits
the documented nextest/SIGTTOU pain on `shell-integration-tests`.
PTY-driven first-interactive-ready can be a follow-up.
## Variants
- `picker_preview/warm/typical-8`
- `picker_preview/cold/typical-8`
Cold uses `BatchSize::PerIteration` (not `SmallInput`): `SmallInput`
calls `setup` for an entire batch up front and then runs timed routines
back-to-back, so only the first iter in each batch is genuinely cold —
the rest hit a freshly populated `.git/wt/cache/`. `PerIteration`
invalidates immediately before every measured iteration; setup is far
cheaper than `wt switch`, so per-iter `Instant::now` doesn't dominate.
`sample_size(10)` + `measurement_time(35s)` per #2685's lead — slow
benches don't benefit from the default 30 samples.
`cfg(unix)`-gated with a no-op `main` on Windows; the picker is
Unix-only and `wt switch` (no args) hits the unavailable path before the
env var is consulted.
## Sample run
```
picker_preview/warm/typical-8 time: [185.62 ms 191.72 ms 200.77 ms]
picker_preview/cold/typical-8 time: [209.34 ms 226.23 ms 239.29 ms]
```
## Test plan
- [x] `cargo bench --bench picker_preview` runs cleanly on both variants
- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass
- [x] New `test_picker_preview_bench_produces_no_output` asserts
`WORKTRUNK_PREVIEW_BENCH=1` keeps stdout/stderr empty (covers the
env-gated branch, locks the no-I/O contract)
- [x] Smoke test: `wt switch` with `WORKTRUNK_PREVIEW_BENCH` unset still
hits the TTY error path (user-visible behavior unchanged)
- [x] Smoke test: `WORKTRUNK_PICKER_DRY_RUN=1` still emits the cache
JSON dump (regression check)
- [x] `/review-codex` pass clean after iterating on three findings
(packed-refs fix already on `main` via #2697 once branch was rebased;
`BatchSize::PerIteration` for true per-iter invalidation; `cfg(unix)`
gate for Windows)
> _This was written by Claude Code on behalf of Maximilian Roos_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:22 -07:00
|
|
|
|
//! Preview pre-compute workload — spawn → all preview tasks drained,
|
|
|
|
|
|
//! skim bypassed (criterion, synthetic repo):
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! ```bash
|
|
|
|
|
|
//! cargo bench --bench picker_preview
|
|
|
|
|
|
//! ```
|
|
|
|
|
|
//!
|
2026-04-15 19:11:37 -07:00
|
|
|
|
//! Per-phase breakdown on a specific repo (a single trace is usually enough
|
|
|
|
|
|
//! to spot where time goes; re-run a few times if you want variance):
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! ```bash
|
|
|
|
|
|
//! RUST_LOG=debug ./target/release/wt -C <repo> switch \
|
|
|
|
|
|
//! 2> >(cargo run -p wt-perf --release -q -- trace > trace.json)
|
|
|
|
|
|
//! # Open trace.json in Perfetto, or run the phase-duration SQL query
|
|
|
|
|
|
//! # documented in benches/CLAUDE.md §"What's on the critical path?".
|
|
|
|
|
|
//! ```
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
|
|
|
|
|
mod items;
|
|
|
|
|
|
mod log_formatter;
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
mod os;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
mod pager;
|
feat(switch): show PR/MR title and description in the worktree pr preview pane (#3167)
## What
The `pr` preview tab for a worktree row in the `wt switch` picker showed
only the reference and URL. It now shows the full PR/MR title and the
description rendered as markdown — the same content the `--prs` rows'
pane already shows.
## How
The data rides the CI fetch the picker already makes, so there's no new
network call:
- `gh pr list --head … --json` is widened with `title,body` (a free
widening of the existing call). GitLab (`glab mr list`), Azure (`az
repos pr list`), and Gitea (`tea api …/pulls`) PR-list fetches are
extended the same way, mapping `description`/`body` to a common field.
Branch-workflow / pipeline paths (no PR) leave the new fields unset.
- Two fields — `title` and `body` — were added to `PrStatus`, which the
fetch fills and the pane reads. They serialize into the local CI cache
(serde default + skip-if-none, so old cache entries stay readable), so
the picker's prime-from-cache first frame can show them before the live
fetch lands.
- The user-facing `wt list --format=json` is unchanged: `JsonCi` is
built field-by-field from `PrStatus` and intentionally does not carry
title/body.
A new `src/commands/picker/pr_pane.rs` module owns the shared rendering
— `header` (reference + title), `metadata_line` (dim label + value at a
fixed column), and `description` (markdown in the house gutter). Both
the worktree pane (`items::render_worktree_pr`) and the `--prs` pane
(`prs::PrSkimItem`) build from these pieces, so the two read alike; the
old `render_pr_description` moved here. The `--prs` pane's output is
byte-identical to before.
On GitHub, `GhPr` (the `--prs` row) now reads `title`/`body` from the
flattened `GitHubPrInfo` rather than its own fields, since the worktree
fetch parses `GitHubPrInfo` directly and the duplicated keys would
collide under `#[serde(flatten)]`.
## Preview-pane caching
The worktree `pr` pane is memoized in the picker's in-memory
`PreviewCache` (the same cache the other tabs use), filled lazily on
first `preview()` and invalidated by the collect handler's `on_update`
whenever the live `pr_status` slot changes — so the markdown body
renders once per slot state rather than on every keystroke while the tab
is active, matching the render-once model the `--prs` rows already
followed. The tab-bar availability check now reads only the slot
discriminant, so it no longer clones the (possibly large) body on every
render. Two inline TODOs mark deliberate scope cuts surfaced in review:
the dead `title`/`body` on GitHub's `--prs` `open_pr_status` (GitLab
already sets `None`), and `title`/`body` intentionally absent from `wt
list --json`.
## Testing
`pr_pane`'s helpers, the worktree pane's title-present/absent and
body-present/absent branches, the full `preview()` → `render_preview`
dispatch (extracted so the mode is testable without the per-process
picker-state file), and the pane memoization + its invalidation are
covered by unit tests; the serde-flatten change is pinned by the
existing GitHub parse test. The four backends' new lines are exercised
by the mocked-CLI `ci_status` integration tests. The picker is
`#[cfg(unix)]`-gated, so its tests don't run on Windows.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:04:24 -07:00
|
|
|
|
mod pr_pane;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
mod preview;
|
perf(picker): cache log/branch-diff/upstream-diff renders to disk (#2628)
The picker pre-computes four previews per item at skeleton time. Three
of them — Log, BranchDiff, UpstreamDiff — are derivable from git object
SHAs at a given terminal width, so rendering them on every \`wt switch\`
duplicates work the prior invocation already did. This adds a SHA-keyed
disk cache alongside the existing \`sha_cache\` so warm-cache
invocations short-circuit the git subprocesses.
## What's cached, by mode
- **BranchDiff / UpstreamDiff**: the rendered diff string itself. Pure
functions of `(base_sha, head_sha, width)` — cache value is what the
existing `compute_*_preview` returns. Default-branch and upstream SHAs
are resolved via `rev-parse` so the key stays stable across `git fetch`.
- **Log**: a small struct (raw `git log --graph` output + per-commit
`(insertions, deletions)` map). The render path recomputes `merge-base`
+ `rev-list` for the dim/bright split and runs `format_log_output`
against `epoch_now()` on every call. Why split: dim/bright shifts as
`main` advances, and relative-time strings drift with wall-clock —
neither is in the SHA-deterministic part. Caching just the raw log +
stats means the cache key stays `(branch_head_sha, w, h)` (no `main`
dimension, no time bucket) while output stays correct.
- **WorkingTree** is intentionally not cached — its inputs include the
mutable working tree, which has no cheap stable hash. Summary already
has its own cache.
Layout:
`.git/wt/cache/picker-preview/{mode}-{sha}[-{sha}]-{w}[-{h}].json`. Diff
modes cache pre-pager strings (the pager step in
`compute_and_page_preview` runs on every read, so changing the
configured pager invalidates nothing). LRU bound is 500 entries —
rendered diffs are tens of KB each, vs the 80-byte SHA-pair entries
`sha_cache` is sized for at 5000.
## State management
Bundled into the existing "git commands cache" user-facing category in
\`wt config state get\` and \`state clear --all\` — both \`sha_cache\`
and the new picker preview cache surface as one count and clear
together. Implementation stays in \`commands/picker/preview_cache.rs\`
(picker-owned, distinct LRU bound) rather than mixing into
\`git/repository/sha_cache.rs\`. Because the picker module is
`#[cfg(unix)]`, two cfg-conditional shims in `state.rs` keep the
JSON/table shape platform-agnostic on Windows.
## Key files
- \`src/commands/picker/preview_cache.rs\` — new module, primitives +
\`LogCacheEntry\` + state integration
- \`src/commands/picker/items.rs\` — cache integration in
\`compute_log_preview\` / \`compute_branch_diff_preview\` /
\`compute_upstream_diff_preview\`; the log path splits cacheable raw
work from per-call dim/format
- \`src/commands/config/state.rs\` — bundled clear/show plus the
cfg-conditional shims
- \`src/cli/config.rs\` — help text for the bundled category
## Tests
Round-trip + width/SHA invalidation in the new module, plus per-mode
short-circuit / writeback tests through each \`compute_*_preview\`.
Includes a regression test for worktrunk-bot's review feedback
(\`log_cache_dim_split_tracks_main_advance\`): branches feature off
main, advances main to include feature's commit, asserts the cached log
re-dims via the stripped bold-green branch decoration.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-06 20:33:03 -07:00
|
|
|
|
pub(crate) mod preview_cache;
|
fix(switch): auto-refresh picker preview when a background compute lands (#3247)
## Problem
The `wt switch` picker's preview pane is served from a `DashMap` cache
filled by background workers on `COLLECT_POOL` (a `git diff HEAD`, a
`git log`, a forge `gh pr view`). skim 4.8 re-reads that cache **only**
inside `run_preview`, which fires only on `Event::RunPreview` — produced
by a selection change or a preview-tab keystroke. The cache-insert path
didn't poke skim, so a compute that finished *after* the single
`RunPreview` the keystroke produced sat in the cache with no event to
surface it: the pane stayed on its `Loading…` placeholder until the user
pressed a key again. That `Press alt-N again to refresh` text was the
manual workaround for exactly this gap, and it was a Windows-CI flake
(PR #3238 papered over it test-side by re-issuing the tab keystroke).
## The skim mechanism this uses
skim 4.8 hands the embedder its event sender at TUI init:
`Skim::event_sender()` returns the `tokio::sync::mpsc::Sender<Event>`
that drives the loop. The picker already captures it (as `render_tx`, a
shared `Arc<OnceLock<…>>`) and pushes `Event::Render` through it for
in-place row repaints. **Pushing `Event::RunPreview` through the same
channel forces `run_preview` to re-read the cache for the
currently-selected row + current mode** — the external injection point
the orchestrator needed. The channel is `1024*1024`-capacity, so the
`try_send` poke is never dropped.
## Approach
New `PreviewNotifier` (`src/commands/picker/preview_notify.rs`) closes
the producer → consumer loop:
- **Consumer side:** every `*SkimItem::preview()` records the selected
row's awaited `(row-key, mode)` via `note_awaiting` — *before* it reads
the cache. That ordering makes the hand-off race-free: if the read
misses, the fill that satisfies it necessarily lands after the read, so
it observes the awaited key already set.
- **Producer side:** the orchestrator routes **every** cache fill
through a single `PreviewOrchestrator::fill` / `fill_external` path,
which calls `notify_filled(key)`. That injects `Event::RunPreview`
**iff** the filled key matches what the selected row is awaiting. A fill
for an off-screen row or a tab the user isn't on matches nothing and
injects nothing — so background pre-compute never thrashes the visible
preview.
`preview()` is only ever called for the selected row, so the single
shared `awaiting` slot always reflects what's on screen; when the
selection changes, the next `RunPreview` updates it.
Two producers feed the panes, both now covered:
- **Orchestrator cache fills** (diff / log / summary / the `--prs`
comments & log fetch) → `notify_filled(key)`, exact-key match.
- **The collect handler's `on_update`** mirrors a row's live `pr_status`
(the `pr` / `comments` panes) and `local_content` (the diff tabs' dim
state) — not cache fills → `notify_row_changed(row_key)`, which re-runs
the selected row's preview on *any* tab when that row's data lands. This
is what flips the `pr` tab from "Fetching PR status…" to the resolved PR
on its own.
Wiring: `render_tx` is constructed before the orchestrator in
`handle_picker` and handed in; it's still published once, inside
`run_skim` after `init_tui`. `generate_and_cache_summary` became
`generate_summary_for_item` (returns the pane; the orchestrator inserts
via `fill`).
## User-visible change
The placeholders drop the now-obsolete "press alt-N … to refresh"
wording — the pane fills in on its own:
```
○ Loading working-tree diff… (was: ○ Loading working-tree diff. Press alt-1 again to refresh.)
○ Generating summary…
○ Fetching PR status for feature… (was: … press alt-6 to refresh)
ⓘ Loading comments… (--prs deferred tabs; was: … press alt-2 again to refresh)
```
## Testing
- `test_switch_picker_preview_auto_refreshes_when_compute_lands` (PTY):
mocks `gh pr view --json comments` behind a 3 s delay, opens a `--prs`
row's comments tab mid-fetch — the comment surfaces **with no further
input** (the orchestrator-fill path).
- `test_switch_picker_pr_tab_auto_resolves_from_fetching` (PTY): the
per-row CI fetch (`gh pr list --head`) is delayed 3 s and unseeded, so
the `pr` tab opens on "Fetching PR status…" and resolves to the live PR
on its own (the `on_update` path).
- Both verified to **time out (stay stranded) when the poke is
disabled**, so they genuinely exercise the mechanism rather than passing
on a cache hit.
- `fill_notifies_only_awaited_key` /
`notify_row_changed_pokes_only_the_selected_row` (unit): the poke fires
for the visible row+mode (resp. row, any mode) and nothing for
off-screen / other-tab keys — the no-thrash guarantee.
- The PTY driver's keystroke re-issue (`nudge` / `is_alt_digit_tab` /
`PREVIEW_REISSUE_INTERVAL`, PR #3238) is removed — the product now
auto-refreshes, so the tests genuinely verify it rather than papering
over a strand. All 37 `switch_picker` PTY tests pass; full `cargo run --
hook pre-merge --yes` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:24:23 -07:00
|
|
|
|
mod preview_notify;
|
Unblock picker first render; add preview dry-run (#2210)
## Problem
On repos with many worktrees, `wt switch` shows a blank terminal for 1–2
seconds before the list appears. Skim 0.20's event loop calls
`SkimItem::preview()` synchronously before `term.draw()`
(`model/mod.rs:715-722`) — any latency inside `preview()` freezes the
whole UI, not just the preview pane. The previous implementation held a
DashMap shard write lock across a git + pager subprocess via
`entry().or_insert_with(...)`, so skim's first render blocked behind
whichever background task was currently computing the first item's
default mode.
## Changes
**Thread pool** (first commit, already reviewed upstream): dedicated
rayon pool for preview/summary pre-compute, sized `2×cores` to match the
global pool's mixed-I/O profile. Extracted `rayon_thread_count()` so the
two sites can't drift.
**Non-blocking `preview()`**: `preview_for_mode` is now a pure cache
read — hit returns content, miss returns a mode-specific placeholder
(`"○ Loading working-tree diff. Press 1 again to refresh."`). Background
tasks compute outside any DashMap lock and use `insert` after, matching
the pattern `generate_and_cache_summary` already used for LLM summaries.
Skim 0.20 doesn't expose a way to re-query preview without user
interaction (`on_item_change` at `previewer.rs:187` bails on unchanged
items), so the placeholder's "press N again" instruction is the
supported refresh path.
**`PreviewOrchestrator`**
(`src/commands/picker/preview_orchestrator.rs`): owns the cache,
dedicated pool, and a pending-task counter. `PendingGuard` decrements on
drop so a panicking task still releases the counter — otherwise
`wait_for_idle` would hang forever on any panic. Exposes
`spawn_preview`, `spawn_summary`, `wait_for_idle`, `dump_cache_json` so
the pipeline is testable without skim.
**`WORKTRUNK_PICKER_DRY_RUN`**: setting the env var runs the full
pre-compute (speculative first-item spawn, collect, full spawn loop,
summaries), waits for all tasks, prints cache inventory as JSON, and
exits instead of launching skim. Useful for diagnosing "previews never
load" bugs from scripts and as the basis for integration tests.
## Testing
Unit tests in `preview_orchestrator.rs` cover end-to-end cache
population (via real `TestRepo` + git subprocesses, no mocks),
duplicate-spawn short-circuiting, and the JSON dump format.
Verified by running `WORKTRUNK_PICKER_DRY_RUN=1 wt switch` in this repo:
14 branches × 5 modes = 70 entries, all non-empty, 5s to full cache
warm.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 22:16:42 -07:00
|
|
|
|
mod preview_orchestrator;
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
mod progressive_handler;
|
2026-06-22 16:34:04 -07:00
|
|
|
|
mod prs;
|
feat(switch): add AI summary preview tab (#1049)
* feat(switch): add AI summary preview tab (tab 5)
Add a fifth preview mode to `wt switch` that shows AI-generated branch
summaries using the configured [commit.generation] LLM command. Summaries
use commit-message format (imperative subject + body) and render through
the standard markdown help renderer for consistent styling.
- Background thread generates summaries in parallel for all branches
- Disk cache in .git/wt-cache/summaries/ with hash-based invalidation
- Graceful fallback: config hint when LLM not configured, dim "no changes"
for default branch
- Shortened tab labels to fit 5 tabs: 1:diff | 2:log | 3:main | 4:upstream | 5:summary
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve merge conflicts from jj revert
After merging main (which reverted jj support), fix two issues:
- Resolve config to access commit_generation (handle_select no longer
receives resolved config directly)
- Restore pub(crate) visibility on execute_llm_command
Co-Authored-By: Claude <noreply@anthropic.com>
* test: add cache and rendering tests for summary module
Add tests for cache round-trip, hash invalidation, file path
sanitization, directory structure, and pre-styled text rendering
to improve codecov/patch coverage.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: add integration test for summary tab and expand cache tests
- Add PTY test for tab 5 showing config hint when LLM not configured
- Add cache round-trip, invalidation, sanitized path, and dir tests
- Add pre-styled text rendering test for dim "no changes" messages
Co-Authored-By: Claude <noreply@anthropic.com>
* test(summary): add coverage for diff computation and LLM generation
Add 10 new unit tests covering `compute_combined_diff`,
`generate_summary`, `generate_all_summaries`, and the single-line
`render_summary` path. Uses real temp git repos with shell-stub LLM
commands (following existing patterns from merge integration tests).
Also refactors test helpers to share git command setup and repo
initialization, eliminating duplication between test cases.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(summary): handle missing default branch + add coverage tests
- compute_combined_diff no longer bails when default_branch() returns
None — wraps branch diff in if-let, preserving working tree diff
- Fix test to use exotic branch name so default_branch() actually
returns None (infer_default_branch_locally checks "main"/"master"/etc)
- Add unit tests for items.rs Summary tab paths (main worktree, feature
branch, cache hit/miss, compute_preview delegation)
- Add error path tests for write_cache (unwritable path, permission
failure)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(summary): address PR review feedback
- Remove is_main() shortcut from compute_summary_preview — was checking
main worktree (git concept) not default branch (different concept)
- Add unicode visual cues to tab labels: 1:diff±, 3:main↕, 4:upstream⇅
- Move summary_items clone closer to its consumer in mod.rs
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(ci): revert unicode tab cues — ambiguous East Asian Width
Characters ±, ↕, ⇅ have East Asian Width "Ambiguous" which skim
renders as double-width on CI, shifting [N/M] alignment by 3 chars.
Revert to plain labels for cross-platform consistency.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(switch): restore unicode symbols in tab labels
Restore ±, ↕, ⇅ symbols to tab labels (1:diff±, 3:main↕, 4:upstream⇅).
These characters are used throughout the codebase and haven't shown
width issues in practice.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(summary): bound concurrent LLM calls with Semaphore
Use the project's existing Semaphore (from src/sync.rs) to limit
concurrent LLM calls to 8 — same pattern as HEAVY_OPS_SEMAPHORE
and CMD_SEMAPHORE.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(switch): adjust snapshot spacing for unicode tab symbols
skim-tuikit uses width_cjk() for header layout, which treats
East Asian Width "Ambiguous" characters (±, ↕) as double-width.
This shifts [N/M] left by 3 columns. Update snapshots to match.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): normalize tab bar padding for cross-platform unicode widths
Skim right-aligns the [N/M] count indicator with padding that varies
depending on whether unicode chars (±, ↕, ⇅) are rendered as single
or double width. Normalize this padding in the snapshot filter so
tests pass regardless of the terminal's East Asian Width handling.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(switch): restore original tab titles, add 5th summary tab
Revert tab labels 1-4 to their original format ("1: HEAD±", "2: log",
"3: main…±", "4: remote⇅") and add "5: summary" as a new 5th tab.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(test): handle skim count overlap with summary tab label
When the 5 restored tab labels use ambiguous-width unicode symbols (±, …, ⇅),
skim's width_cjk() treats them as double-width, leaving insufficient space for
the count indicator. This causes the count to overlap with "summary" (e.g.,
"summary1/4") or truncate it ("summar1/28"). Add a targeted regex filter that
normalizes this overlap before the generic whitespace-padded count filter runs.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(test): avoid typos lint on truncated word in snapshot regex
Use `summary?` (optional `y`) instead of `summar` to avoid the typos
spell checker flagging the partial word.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(ci): restructure review skill workflow and fix dead sticky comment (#1056)
- Reorder as explicit numbered workflow: pre-flight checks before
expensive diff analysis to avoid redundant work
- Remove redundant "read CLAUDE.md" (already in system prompt)
- Filter dedup check by bot identity so human approvals don't
cause the bot to skip its review
- Accept brief approval bodies (matches actual bot behavior)
- Replace {owner}/{repo} placeholders with derived $REPO variable
- Add --paginate on comment fetching for large PRs
- Remove sticky comment references from skill (bot puts feedback
in review bodies, not stdout — sticky comment stopped working
after PAT switch in #1052)
- Add TODO on use_sticky_comment in workflow
- Restore gh pr comment prohibition with correct justification
Co-authored-by: Claude <noreply@anthropic.com>
* fix(shell): harden nushell wrapper and improve diagnostics (#1059)
* fix(shell): harden nushell wrapper and improve diagnostics
- Move LAST_EXIT_CODE capture inside do{} block so it reflects the
actual command exit code, not a subsequent operation
- Wrap directive processing in try/catch to ensure temp file cleanup
on error
- Include nushell vendor autoload paths in scan_for_detection_details
so `wt config show` reports nushell integration status
- Add "nu" to the supported shells hint shown on unsupported shells
- Fix detection tests to use actual nushell config line patterns
- Document why non-cd directives delegate to sh -c
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(shell): remove try/catch from nushell directive cleanup
Drop error-path cleanup for the temp directive file. On error, the file
persists in /tmp as a useful debugging artifact (the OS cleans it up).
This matches bash and fish which already use a single rm on the happy
path with no error wrapping.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(shell): PowerShell wrapper swallows -D flag as -Debug (#1057)
* fix(shell): PowerShell wrapper swallows -D flag as -Debug (#885)
The `[Parameter(ValueFromRemainingArguments)]` attribute promoted the
wrapper to an "advanced function", which adds common parameters like
-Debug and -Verbose. PowerShell then consumed `-D` as `-Debug` instead
of passing it to wt.exe — so `wt remove -D` silently lost the flag.
Replace with `$args` (automatic variable for simple functions) which
passes all arguments through unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(test): use .ps1 mock for cross-platform PowerShell test
The shell script mock (#!/bin/sh) doesn't work on Windows. Use a .ps1
script instead — pwsh can invoke it directly with &, and pwsh is already
required for this test.
Co-Authored-By: Claude <noreply@anthropic.com>
* style: apply cargo fmt to PowerShell test
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(ci): use empty body for LGTM approvals instead of fluff (#1060)
The review bot was generating summary prose like "Clean hardening of
the nushell wrapper..." when it had no issues to raise. An empty
approval is less noisy — the thumbs-up reaction is sufficient signal.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(list): handle empty repos (no commits) gracefully (#1058)
* fix(list): handle empty repos (no commits) gracefully
Skip commit-dependent tasks for unborn branches (null OID) using a
COMMIT_TASKS constant, following the existing EXPENSIVE_TASKS pattern.
Filter null OIDs from timestamp batching, accept unborn default branch
in validation, and render empty commit/age cells instead of garbage.
Closes #885
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: address codecov/patch coverage gaps
- Remove unreachable COMMIT_TASKS check from work_items_for_branch (null
OIDs only appear in worktree HEAD, never in git for-each-ref)
- Restructure json_output null OID handling to eliminate dead branch
- Pre-set default branch config in test to exercise is_unborn_head_branch path
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: remove unused BranchRef::has_commits() (dead code)
Only WorktreeInfo::has_commits() is called in the dispatch code.
BranchRef::has_commits() was never referenced, causing a codecov gap.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(switch): unify preview mode handling
All 5 preview modes now follow the same cache → compute → post-process
path in preview_for_mode, eliminating the Summary early-return special
case. Summary precomputation uses rayon (queued after tabs 1-4) instead
of a separate std::thread::spawn + thread::scope wrapper.
Threading simplified from:
rayon::spawn × (N × 4) ← tabs 1-4
std::thread::spawn ← wrapper
└── std::thread::scope ← N scoped threads
└── LLM_SEMAPHORE ← rate limit
To:
rayon::spawn × (N × 4) ← tabs 1-4 (queued first)
rayon::spawn × N ← summaries (queued last, semaphore inside)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(switch): gate summary tab behind [list] summary config
Summary generation is opt-in via `[list] summary = true` (default: false)
to avoid surprise LLM calls for users who have `[commit.generation]`
configured. Both settings are required for summaries to fire.
Adds documentation for the feature in switch help, FAQ (commands we run),
and llm-commits page (new "Picker summaries" section).
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use ResolvedConfig directly after main merge
The merge with main changed handle_select to receive ResolvedConfig
instead of UserConfig, so the .resolved() call is no longer needed.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: update summary preview snapshot for config hint
The hint text now includes [list] summary = true in addition to the
[commit.generation] example.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: replace missed shlex::try_quote with shell_escape
The shlex removal in #1065 missed one call site in the switch suggestion
context builder. Replace with shell_escape::escape to match the rest of
the codebase.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
2026-02-16 15:15:59 -08:00
|
|
|
|
mod summary;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
2026-03-23 12:18:42 -07:00
|
|
|
|
use std::cell::RefCell;
|
2026-02-10 23:45:02 -08:00
|
|
|
|
use std::io::IsTerminal;
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-03-23 12:18:42 -07:00
|
|
|
|
use std::rc::Rc;
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
|
|
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
use std::time::Instant;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
feat(list): custom template columns and cached PR numbers in the picker (#3073)
Two display features for `wt list` and the interactive picker, developed
together because they share the column-layout and progressive-rendering
machinery.
## Custom columns (`[list.custom-columns]`)
Each `[list.custom-columns.<Header>]` entry in user config adds a `wt
list` column: a minijinja template rendered per row over `branch`,
`worktree_path`, `worktree_name`, and `vars.*`, with optional `width`
and drop priority. Values expand before the skeleton renders, from
in-memory data only — `vars` come from the bulk git-config snapshot, so
no subprocess runs per cell. Widths are measured from content like the
Branch and Path columns; a column that is empty on every row is dropped.
Unknown variables and misspelled filters abort `wt list` with the
available-variables hint; undefined values render as empty cells (the
intended sparse-column shape). `wt list --format json` gains a `columns`
map per item, and its `vars` field now reads from the snapshot too (the
previous `--get-regexp` line-parse truncated multiline values). The
picker shares the row renderer, so the columns appear there as well; a
broken definition degrades to no columns plus a stashed warning, since
collect runs while skim owns the terminal.
The key is `[list.custom-columns]`, not `[list.columns]`, to avoid
colliding with the column-visibility toggles in #3065 (which claims
`[list.columns]` as a flat map of built-in-column bools — a mutually
exclusive serde shape for the same protected key). Namespacing here lets
both land independently.
Ref #1982 — the custom-columns proposal lives in that thread. The
issue's own title is a separate directory-naming request, so this
doesn't close it.
## Cached PR/MR numbers in the picker
The picker skips the networked CiStatus task, so until now it had no CI
column at all. Cached statuses are local data, though: collect now fills
rows from `.git/wt/cache/ci-status/` when the task is skipped under a
progressive handler, so PR/MR numbers fetched by earlier `wt list
--full` or statusline runs render in the picker — aligned with the same
`MaxPrNumber` ratchet width `wt list` uses, and with zero network
access.
A valid cache entry renders as-is. An entry whose TTL passed or whose
branch head moved keeps its PR/MR number dimmed: the number still
identifies the PR when the pipeline color may be outdated. Expired
entries without a number are dropped. The CI column is allocated only
when some row had a usable entry, and rows the cache can't fill resolve
to blank rather than a pending placeholder, since no task repaints them.
## Key files
- `src/config/expansion.rs`, `src/config/user/sections.rs`,
`src/git/repository/config.rs` — column resolution, the template
environment, and the bulk git-config snapshot.
- `src/commands/list/layout.rs`, `src/commands/list/render.rs` — column
width allocation and cell rendering.
- `src/commands/list/ci_status/mod.rs` — `populate_from_cache`, the
cache-only fill.
- `src/commands/picker/mod.rs` — the dry-run dump
(`WORKTRUNK_PICKER_DRY_RUN`) that makes picker row content assertable in
tests.
## Testing
Integration tests cover both features: custom columns (table render,
JSON output, empty-column drop, invalid-template error) and the picker
(cached PR numbers appear in the dry-run dump, uncached branches stay
blank). Unit tests cover the cache-population logic (valid,
expired-with-number, head-moved, dropped). Verified against the full
`cargo run -- hook pre-merge --yes` gate locally.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:37:22 -07:00
|
|
|
|
use ansi_str::AnsiStr;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
use anyhow::Context;
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
use color_print::cformat;
|
2026-03-23 12:18:42 -07:00
|
|
|
|
// bounded/unbounded/Sender are re-exported by skim::prelude
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
use skim::prelude::*;
|
2026-03-23 12:18:42 -07:00
|
|
|
|
use skim::reader::CommandCollector;
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
use skim::tui::event::ActionCallback;
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
use worktrunk::HookType;
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
use worktrunk::config::Approvals;
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use worktrunk::git::{ErrorExt, Repository, current_or_recover};
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
use worktrunk::path::format_path_for_display;
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use worktrunk::styling::{
|
2026-06-30 20:56:08 -07:00
|
|
|
|
eprintln, error_message, hint_message, info_message, strip_osc8_hyperlinks, warning_message,
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
};
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder};
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
use super::hooks::HookAnnouncer;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
use super::list::collect;
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use super::list::model::{BranchScope, ItemKind, ListItem};
|
2026-04-29 10:56:59 -07:00
|
|
|
|
use super::list::progressive::RenderTarget;
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use super::list::render::PLACEHOLDER;
|
2026-03-23 00:07:10 -07:00
|
|
|
|
use super::repository_ext::{RemoveTarget, RepositoryCliExt};
|
refactor(switch): unify the picker and argument-path switch pipelines (#2858)
## Summary
`wt switch <branch>` and the interactive picker (`wt switch` with no
argument) each ran the same switch sequence as separate, parallel code.
[#2845](https://github.com/max-sixty/worktrunk/pull/2845) made the two
paths *behave* identically; this makes the *code* identical too — a
single `SwitchPipeline` that both entry points build and `.run()`.
`SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the
whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks,
source-identity capture, `plan_switch` → `approve_switch_hooks` →
`validate_switch_templates` → `execute_switch`, output, background
hooks, and `--execute`. Each caller now only resolves a branch
identifier and loads config. The picker-vs-argument differences
(`--execute`, the shell-integration offer, source-identity capture) are
struct field values, not divergent branches.
## Bug fixed
The duplication hid a real bug: the picker passed `yes = true` to
`run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks
without a prompt** — unapproved code from a freshly cloned
`.config/wt.toml` running silently. Every other hook the picker runs
(`post-switch`, `pre-create`, `post-create`) already went through the
approval prompt. With one shared `run_pre_switch_hooks` call gated by
the pipeline's single `verify`/`yes` pair, the picker (which has no
`--yes`) now prompts for project `pre-switch` hooks like `wt switch
<branch>` does — and the two paths can't drift on hook approval again.
## Reviewer notes
- One intentional reorder on the argument path:
`offer_bare_repo_worktree_path_fix` now runs before
`run_pre_switch_hooks` (the picker already used this order). The fix
only mutates `worktree-path` config, which pre-switch hooks never read —
behavior-neutral.
- `capture_switch_source` runs inside `run()` after pre-switch hooks —
the same relative position as the old `run_switch`.
- Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to
private; `worktree/mod.rs` re-exports trimmed accordingly.
## Testing
Pure refactor — the existing `test_switch_*`,
`test_switch_format_json_*`, and `test_switch_picker_*` suites cover
behavior preservation.
`test_switch_picker_pre_switch_hook_requires_approval` is a new
regression test for the bug fix (verified to fail under the bug).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:47:18 -07:00
|
|
|
|
use super::worktree::{RemoveResult, SwitchPipeline};
|
2026-05-20 21:05:19 -07:00
|
|
|
|
use crate::cli::SwitchFormat;
|
2026-05-24 16:12:09 -07:00
|
|
|
|
use crate::output::{BackgroundFallbackMode, handle_remove_output};
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
use worktrunk::git::{BranchDeletionMode, delete_branch_if_safe};
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use items::{LocalContent, LocalContentSlot, PreviewCache, ShortcutTable, WORKTREE_OUTPUT_PREFIX};
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
use preview::{PreviewLayout, PreviewMode, PreviewState, PreviewStateData};
|
Unblock picker first render; add preview dry-run (#2210)
## Problem
On repos with many worktrees, `wt switch` shows a blank terminal for 1–2
seconds before the list appears. Skim 0.20's event loop calls
`SkimItem::preview()` synchronously before `term.draw()`
(`model/mod.rs:715-722`) — any latency inside `preview()` freezes the
whole UI, not just the preview pane. The previous implementation held a
DashMap shard write lock across a git + pager subprocess via
`entry().or_insert_with(...)`, so skim's first render blocked behind
whichever background task was currently computing the first item's
default mode.
## Changes
**Thread pool** (first commit, already reviewed upstream): dedicated
rayon pool for preview/summary pre-compute, sized `2×cores` to match the
global pool's mixed-I/O profile. Extracted `rayon_thread_count()` so the
two sites can't drift.
**Non-blocking `preview()`**: `preview_for_mode` is now a pure cache
read — hit returns content, miss returns a mode-specific placeholder
(`"○ Loading working-tree diff. Press 1 again to refresh."`). Background
tasks compute outside any DashMap lock and use `insert` after, matching
the pattern `generate_and_cache_summary` already used for LLM summaries.
Skim 0.20 doesn't expose a way to re-query preview without user
interaction (`on_item_change` at `previewer.rs:187` bails on unchanged
items), so the placeholder's "press N again" instruction is the
supported refresh path.
**`PreviewOrchestrator`**
(`src/commands/picker/preview_orchestrator.rs`): owns the cache,
dedicated pool, and a pending-task counter. `PendingGuard` decrements on
drop so a panicking task still releases the counter — otherwise
`wait_for_idle` would hang forever on any panic. Exposes
`spawn_preview`, `spawn_summary`, `wait_for_idle`, `dump_cache_json` so
the pipeline is testable without skim.
**`WORKTRUNK_PICKER_DRY_RUN`**: setting the env var runs the full
pre-compute (speculative first-item spawn, collect, full spawn loop,
summaries), waits for all tasks, prints cache inventory as JSON, and
exits instead of launching skim. Useful for diagnosing "previews never
load" bugs from scripts and as the basis for integration tests.
## Testing
Unit tests in `preview_orchestrator.rs` cover end-to-end cache
population (via real `TestRepo` + git subprocesses, no mocks),
duplicate-spawn short-circuiting, and the JSON dump format.
Verified by running `WORKTRUNK_PICKER_DRY_RUN=1 wt switch` in this repo:
14 branches × 5 modes = 70 entries, all non-empty, 5s to full cache
warm.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 22:16:42 -07:00
|
|
|
|
use preview_orchestrator::PreviewOrchestrator;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
fix(picker): stash collect warnings until skim releases the terminal (#2627)
## Summary
`collect::collect` emits warnings on stderr (stale default branch,
batch-fetch failure, drain timeout, per-row task errors). On the `wt
list` path that's fine. On `wt switch`, collect runs on a background
thread while skim's TUI owns the terminal — eprintln overlays the
rendered frame and corrupts skim's clear math, leaving fragments visible
after the user picks.
Reproducer (synthetic picker-test repo with a stale
`worktrunk.default-branch` set):
```
▲ Configured default branch ghost-branch does not exist locally
↳ To reset, run wt config state default-branch clear
```
…appears overlaid on picker rows mid-render.
## Approach
Warnings flow through a new `PickerProgressHandler::stash_warning`. The
picker holds an `Arc<Mutex<Vec<String>>>` shared with its handler,
collect appends from the bg thread, and the picker drains and emits the
lines after `Skim::run_with` returns (and in the dry-run path after the
bg thread joins). Late warnings still in flight on the bg thread fall on
the floor with the thread, per the existing "don't join after skim"
rule.
`wt list`'s stderr behavior is unchanged — when `progressive_handler` is
`None`, the same closure writes straight to stderr.
The drain-timeout warning + hint that previously hardcoded `wt list` is
now subcommand-agnostic and follows `writing-user-outputs` patterns:
`"Listing worktrees timed out after Xs"`, command at end of clause,
semicolon between alternatives, `-vv` last.
## Test infrastructure
Three small extractions made the new code testable end-to-end and
brought patch coverage up from 66.7% to 100%:
- `drain_stashed_warnings(&Mutex<Vec<String>>)` in `picker/mod.rs` —
both drain call sites collapse to one line; helper body has dedicated
unit tests.
- `format_drain_timeout_diag(received_count, &items)` in
`collect/mod.rs` — pure formatter; snapshot-tested for the no-blocked
and blocked-items paths.
- `handle_drain_timeout(drain_outcome, collect_deadline, &emit)` in
`collect/mod.rs` — wraps the previously-untestable
`DrainOutcome::TimedOut` branch (`DRAIN_TIMEOUT` is 120s with no test
seam). Three unit tests synthesize `DrainOutcome` values directly to
cover all branches: timeout-fires, intentional-truncation,
complete-outcome.
Plus a new integration test in `switch_picker_dry_run.rs` that runs the
picker in dry-run mode against a stale `worktrunk.default-branch` and
asserts the warning + reset hint reach stderr after the bg thread joins.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` — 3508 tests pass, pre-commit
clean (8 new tests across the helpers above).
- [x] `wt list` warning snapshot tests still pass — non-picker stderr
unchanged.
- [x] Manual repro: `WORKTRUNK_PICKER_DRY_RUN=1 wt switch --no-cd`
against picker-test with a stale default branch now surfaces both
warning lines on stderr after the picker exits.
> _This was written by Claude Code on behalf of @max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-06 20:39:01 -07:00
|
|
|
|
/// Drain stashed warnings to stderr. Called after skim has released the
|
|
|
|
|
|
/// terminal (or in the dry-run path after the bg thread joins) — eprintln
|
|
|
|
|
|
/// during the picker would corrupt skim's frame, so collect routes warnings
|
|
|
|
|
|
/// through `PickerProgressHandler::stash_warning` and we emit them here.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// TODO(picker-feedback): the declined-removal diagnostics (the main-worktree /
|
|
|
|
|
|
/// dirty / unmerged "can't remove this row" messages from the `alt-x` keep paths)
|
|
|
|
|
|
/// only surface here, on exit — the user presses `alt-x`, the row visibly stays,
|
|
|
|
|
|
/// and the reason scrolls past after they quit. Consider a short in-picker message
|
|
|
|
|
|
/// at `alt-x` time so the *why* lands immediately. skim has no footer slot (see the
|
|
|
|
|
|
/// dropped Stall-indicator work), so the realistic slot is the header line — swap
|
|
|
|
|
|
/// it to a transient "main worktree can't be removed" for a beat, then restore. The
|
|
|
|
|
|
/// stash stays the fallback for background failures that surface after exit.
|
fix(picker): stash collect warnings until skim releases the terminal (#2627)
## Summary
`collect::collect` emits warnings on stderr (stale default branch,
batch-fetch failure, drain timeout, per-row task errors). On the `wt
list` path that's fine. On `wt switch`, collect runs on a background
thread while skim's TUI owns the terminal — eprintln overlays the
rendered frame and corrupts skim's clear math, leaving fragments visible
after the user picks.
Reproducer (synthetic picker-test repo with a stale
`worktrunk.default-branch` set):
```
▲ Configured default branch ghost-branch does not exist locally
↳ To reset, run wt config state default-branch clear
```
…appears overlaid on picker rows mid-render.
## Approach
Warnings flow through a new `PickerProgressHandler::stash_warning`. The
picker holds an `Arc<Mutex<Vec<String>>>` shared with its handler,
collect appends from the bg thread, and the picker drains and emits the
lines after `Skim::run_with` returns (and in the dry-run path after the
bg thread joins). Late warnings still in flight on the bg thread fall on
the floor with the thread, per the existing "don't join after skim"
rule.
`wt list`'s stderr behavior is unchanged — when `progressive_handler` is
`None`, the same closure writes straight to stderr.
The drain-timeout warning + hint that previously hardcoded `wt list` is
now subcommand-agnostic and follows `writing-user-outputs` patterns:
`"Listing worktrees timed out after Xs"`, command at end of clause,
semicolon between alternatives, `-vv` last.
## Test infrastructure
Three small extractions made the new code testable end-to-end and
brought patch coverage up from 66.7% to 100%:
- `drain_stashed_warnings(&Mutex<Vec<String>>)` in `picker/mod.rs` —
both drain call sites collapse to one line; helper body has dedicated
unit tests.
- `format_drain_timeout_diag(received_count, &items)` in
`collect/mod.rs` — pure formatter; snapshot-tested for the no-blocked
and blocked-items paths.
- `handle_drain_timeout(drain_outcome, collect_deadline, &emit)` in
`collect/mod.rs` — wraps the previously-untestable
`DrainOutcome::TimedOut` branch (`DRAIN_TIMEOUT` is 120s with no test
seam). Three unit tests synthesize `DrainOutcome` values directly to
cover all branches: timeout-fires, intentional-truncation,
complete-outcome.
Plus a new integration test in `switch_picker_dry_run.rs` that runs the
picker in dry-run mode against a stale `worktrunk.default-branch` and
asserts the warning + reset hint reach stderr after the bg thread joins.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` — 3508 tests pass, pre-commit
clean (8 new tests across the helpers above).
- [x] `wt list` warning snapshot tests still pass — non-picker stderr
unchanged.
- [x] Manual repro: `WORKTRUNK_PICKER_DRY_RUN=1 wt switch --no-cd`
against picker-test with a stale default branch now surfaces both
warning lines on stderr after the picker exits.
> _This was written by Claude Code on behalf of @max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-06 20:39:01 -07:00
|
|
|
|
fn drain_stashed_warnings(stash: &Mutex<Vec<String>>) {
|
|
|
|
|
|
for line in stash.lock().unwrap().drain(..) {
|
|
|
|
|
|
eprintln!("{line}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 21:52:29 -08:00
|
|
|
|
/// Action selected by the user in the picker.
|
|
|
|
|
|
enum PickerAction {
|
|
|
|
|
|
/// Switch to the selected worktree (Enter key).
|
|
|
|
|
|
Switch,
|
|
|
|
|
|
/// Create a new worktree from the search query (alt-c).
|
|
|
|
|
|
Create,
|
2026-03-23 12:18:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// The alt-x removal target parsed back out of a row's `output()` token.
|
2026-05-21 19:18:22 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// A worktree-backed row's token is `worktree-path:<path>` (paths are
|
|
|
|
|
|
/// unique — detached worktrees would otherwise collide on the shared
|
|
|
|
|
|
/// `(detached)` label); a branch-only row's token is the bare branch name.
|
|
|
|
|
|
enum PickerRemovalTarget {
|
|
|
|
|
|
WorktreePath(PathBuf),
|
|
|
|
|
|
Branch(String),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PickerRemovalTarget {
|
|
|
|
|
|
fn from_signal(signal: &str) -> Option<Self> {
|
|
|
|
|
|
let signal = signal.trim();
|
|
|
|
|
|
if signal.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(path) = signal.strip_prefix(WORKTREE_OUTPUT_PREFIX) {
|
|
|
|
|
|
if path.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Some(Self::WorktreePath(PathBuf::from(path)));
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(Self::Branch(signal.to_string()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolve the switch identifier for a selected picker row, decoded from its
|
|
|
|
|
|
/// `output()` token: the worktree path for any worktree-backed row, the branch
|
|
|
|
|
|
/// name for a branch-only row.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `wt switch` accepts a worktree path for any existing worktree (`plan_switch`
|
|
|
|
|
|
/// phase 2b), so a worktree-backed row always switches by its unique path —
|
|
|
|
|
|
/// detached *and* branched alike. A branch-only row has no worktree, so its
|
|
|
|
|
|
/// branch name is the only handle.
|
|
|
|
|
|
///
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
/// Decoding `output()` rather than `downcast_ref::<PickerRow>()` also
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
/// sidesteps skim's cross-thread `TypeId` mismatch, which can make the
|
2026-05-21 19:18:22 -07:00
|
|
|
|
/// downcast fail when the item originates on the reader thread.
|
|
|
|
|
|
fn picker_item_identifier(item: &dyn SkimItem) -> String {
|
|
|
|
|
|
let output = item.output().to_string();
|
|
|
|
|
|
match PickerRemovalTarget::from_signal(&output) {
|
|
|
|
|
|
Some(PickerRemovalTarget::WorktreePath(path)) => path.to_string_lossy().into_owned(),
|
|
|
|
|
|
_ => output,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// skim's [`CommandCollector`] for the picker's `reload` actions. Only `alt-r`
|
|
|
|
|
|
/// (`reload(refresh)`) reaches it now — `alt-x` removal runs synchronously through
|
|
|
|
|
|
/// [`AltXRemover`] instead of a `reload` (see its docs). `invoke` re-runs the
|
|
|
|
|
|
/// collect pipeline for a refresh and otherwise re-streams the current rows.
|
|
|
|
|
|
struct PickerCollector {
|
|
|
|
|
|
/// The picker's row list (shared with the handler's `shared_items` and the
|
|
|
|
|
|
/// [`AltXRemover`]). `invoke` re-streams it when a `reload` isn't a refresh.
|
|
|
|
|
|
items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
|
|
|
|
|
|
/// Re-runs the collect pipeline for the `alt-r` refresh: `reload(refresh)`
|
|
|
|
|
|
/// routes here, and [`PipelineFactory::spawn`] streams a fresh item list
|
|
|
|
|
|
/// back. Shared (`Rc`) with `handle_picker`, which used it for the initial
|
|
|
|
|
|
/// spawn.
|
|
|
|
|
|
factory: Rc<PipelineFactory>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// What an `alt-x` press did to the selected row, so the keybinding callback knows
|
|
|
|
|
|
/// how to refresh skim's view (see [`AltXRemover::apply`] and
|
|
|
|
|
|
/// [`install_remove_keybinding`]).
|
|
|
|
|
|
enum RemovalEffect {
|
|
|
|
|
|
/// The row left the list (`items` shrank): the callback resyncs skim's pool
|
|
|
|
|
|
/// from the shrunk list ([`resync_pool`]).
|
|
|
|
|
|
Dropped,
|
|
|
|
|
|
/// The row stayed but its content changed (morphed to `/ branch` in place):
|
|
|
|
|
|
/// the callback repaints it and refreshes its preview.
|
|
|
|
|
|
Morphed,
|
|
|
|
|
|
/// The row stayed unchanged (the removal was declined or kept): the callback
|
|
|
|
|
|
/// just re-anchors and repaints.
|
|
|
|
|
|
Kept,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Runs `alt-x` removal for the selected picker row, **synchronously on skim's
|
|
|
|
|
|
/// event loop** rather than through skim's `reload`.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// # Why not `reload`
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// `alt-x` used to be `reload(remove {})`. skim's `handle_reload` clears the item
|
|
|
|
|
|
/// pool and restarts the matcher *before* the new rows stream in, so the matcher
|
|
|
|
|
|
/// runs once against the empty pool, `Replace`s `item_list` with nothing, and
|
|
|
|
|
|
/// skim's render clamp resets the cursor to the top (`current = 0`). A
|
|
|
|
|
|
/// `reposition` action then snapped it back — but for the frames in between, the
|
|
|
|
|
|
/// `>` pointer flashed to the top row. The fix removes the `reload`: the keybinding
|
|
|
|
|
|
/// callback mutates the row list and rebuilds the pool itself ([`resync_pool`]) so
|
|
|
|
|
|
/// the matcher only ever sees the post-removal list (never empty) and the cursor
|
|
|
|
|
|
/// holds its slot. The row that slides into the removed row's place lands under the
|
|
|
|
|
|
/// cursor for free, with no flash.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// # Send
|
2026-03-23 22:42:15 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// The callback skim runs for a keybinding must be `Send`, so this holds only
|
|
|
|
|
|
/// `Send` state (every field is an `Arc`, or a `Repository`, which is `Send`) — it
|
|
|
|
|
|
/// can't carry the collector's `Rc<PipelineFactory>`. It owns the morph/keep
|
|
|
|
|
|
/// shared slots directly instead of reaching them through the factory.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Git operations (worktree removal, branch deletion) still run on a background
|
|
|
|
|
|
/// thread — `apply` is on skim's event loop and blocking it would freeze the TUI.
|
|
|
|
|
|
/// The row is mutated optimistically; if the background removal finds the target
|
|
|
|
|
|
/// survived ([`removal_target_still_present`]) it restores the row
|
|
|
|
|
|
/// ([`restore_failed_removal`] / [`revert_morph`]) and stashes why.
|
|
|
|
|
|
struct AltXRemover {
|
|
|
|
|
|
/// The picker's row list (shared with [`PickerCollector`] and the handler).
|
|
|
|
|
|
/// `apply` drops a row from it for the drop path; the callback then rebuilds
|
|
|
|
|
|
/// skim's pool from it.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
|
|
|
|
|
|
repo: Repository,
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
/// Approvals snapshot, loaded once at picker startup. A queued removal runs
|
|
|
|
|
|
/// its `pre-remove` / `post-remove` / `post-switch` hooks only when every
|
|
|
|
|
|
/// one is in here — the picker can't show an approval prompt mid-render, so
|
|
|
|
|
|
/// unapproved project commands are skipped, never run. See
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
/// [`approved_removal_plan`].
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
approvals: Arc<Approvals>,
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
/// skim's event sender, published once the TUI is initialized (same
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// `OnceLock` the progressive handler pushes `Event::Render` through). A
|
|
|
|
|
|
/// background removal that fails injects a [`resync_pool_action`] through it to
|
|
|
|
|
|
/// re-show the restored row. `None` until the TUI is up — but `alt-x` can only
|
|
|
|
|
|
/// fire after skim is showing rows, so it's always set by then.
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// Same warning stash the progressive handler fills (drained to stderr once
|
|
|
|
|
|
/// skim releases the terminal). A failed background removal pushes a
|
|
|
|
|
|
/// `worktree kept` warning here so the user learns the row that flickered
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// back (or un-morphed) didn't actually go away. See [`restore_failed_removal`]
|
|
|
|
|
|
/// and [`revert_morph`].
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
stashed_warnings: Arc<Mutex<Vec<String>>>,
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// `alt-y` / `alt-o` lookup table (token → branch + URL). A morph re-keys the
|
|
|
|
|
|
/// row's entry from the worktree token to the branch token. Shared with the
|
|
|
|
|
|
/// handler (which fills it) and the shortcut keybindings (which read it).
|
|
|
|
|
|
shortcut_table: ShortcutTable,
|
|
|
|
|
|
/// The picker's full-width layout, handed over once the rows land. A morph
|
|
|
|
|
|
/// renders the `/ branch` row on this grid so it lines up with the worktree
|
|
|
|
|
|
/// rows. Shared with the handler (which fills it).
|
|
|
|
|
|
layout_slot: Arc<Mutex<Option<crate::commands::list::layout::LayoutConfig>>>,
|
2026-06-30 20:56:08 -07:00
|
|
|
|
/// The header's transient-message slot, shared with the header item. A
|
|
|
|
|
|
/// declined `alt-x` (current worktree, or an unmerged branch-only row) keeps
|
|
|
|
|
|
/// its row in place, so [`flash_header`](Self::flash_header) drops a short
|
|
|
|
|
|
/// "couldn't remove" line into the header for a beat — the *why* lands
|
|
|
|
|
|
/// immediately, not only when the stash drains on exit. See
|
|
|
|
|
|
/// [`items::HeaderFlash`].
|
|
|
|
|
|
header_flash: Arc<items::HeaderFlash>,
|
2026-03-23 12:18:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 20:56:08 -07:00
|
|
|
|
/// How long a declined-`alt-x` header flash stays up before it self-clears and
|
|
|
|
|
|
/// the column labels return — long enough to read, short enough not to linger.
|
|
|
|
|
|
const HEADER_FLASH_DURATION: std::time::Duration = std::time::Duration::from_millis(2500);
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
impl AltXRemover {
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
/// Build removal state from a fresh `Repository` so picker reloads after a
|
|
|
|
|
|
/// background removal do not reuse the startup worktree inventory cache.
|
2026-05-21 19:18:22 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// `target` carries the exact worktree path or branch name decoded from
|
|
|
|
|
|
/// the row's `output()` token — no `git worktree list` lookup, so a
|
|
|
|
|
|
/// detached row can't be confused with another detached row.
|
|
|
|
|
|
fn prepare_removal(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
target: &PickerRemovalTarget,
|
|
|
|
|
|
) -> anyhow::Result<(Repository, RemoveResult)> {
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
let repo = Repository::at(self.repo.discovery_path())?;
|
|
|
|
|
|
|
|
|
|
|
|
// Validate removal before touching the list. prepare_worktree_removal
|
|
|
|
|
|
// runs a few git commands (~15-20ms) — acceptable on skim's event loop.
|
|
|
|
|
|
// Only remove the item and spawn background deletion if this succeeds.
|
|
|
|
|
|
let caller_path = repo.current_worktree().root().ok();
|
|
|
|
|
|
|
|
|
|
|
|
let result = {
|
2026-05-21 19:18:22 -07:00
|
|
|
|
let remove_target = match target {
|
|
|
|
|
|
PickerRemovalTarget::WorktreePath(path) => RemoveTarget::Path(path),
|
|
|
|
|
|
PickerRemovalTarget::Branch(branch) => RemoveTarget::Branch(branch),
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
};
|
|
|
|
|
|
repo.prepare_worktree_removal(
|
2026-05-21 19:18:22 -07:00
|
|
|
|
remove_target,
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
false,
|
|
|
|
|
|
caller_path,
|
|
|
|
|
|
None,
|
|
|
|
|
|
None,
|
|
|
|
|
|
)?
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Ok((repo, result))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
/// Execute a queued removal in the background.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A `RemovedWorktree` result goes through [`handle_remove_output`] in its
|
|
|
|
|
|
/// silent (TUI) mode — the git worktree removal with no `wt`-generated
|
|
|
|
|
|
/// messages, spinner, or `cd` directive (skim owns the terminal). Its
|
|
|
|
|
|
/// `pre-remove` / `post-remove` / `post-switch` hooks run only when they're
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
/// already approved ([`approved_removal_plan`] — a read-only `Approvals`
|
|
|
|
|
|
/// filter, no prompt): the picker can't prompt mid-render, so unapproved
|
|
|
|
|
|
/// project commands are dropped from the plan, never run. (A hook that
|
|
|
|
|
|
/// *does* run still
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
/// streams its own output to stderr, like any hook — a rough edge of
|
|
|
|
|
|
/// removing inside the picker.) A `BranchOnly` result just deletes the
|
|
|
|
|
|
/// branch if it's safe to.
|
2026-03-26 05:50:05 -07:00
|
|
|
|
///
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
/// Called from a background thread after the picker optimistically removes
|
|
|
|
|
|
/// the item from the list, so the whole operation runs off skim's event loop
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// and the TUI stays responsive. Only reached for a removal
|
|
|
|
|
|
/// [`removal_will_remove_target`] predicts will remove the target — a
|
|
|
|
|
|
/// predictably-kept unmerged branch never gets here. The caller does not infer
|
|
|
|
|
|
/// the outcome from this `Result` — a removal can fail before *or* after the
|
|
|
|
|
|
/// worktree is physically gone (rendering or spawning a
|
|
|
|
|
|
/// `post-remove`/`post-switch` hook can error during the announcer flush, which
|
|
|
|
|
|
/// runs after the dir is renamed into `.git/wt/trash/`), and a `BranchOnly`
|
|
|
|
|
|
/// delete that raced from integrated to unmerged returns `Ok` with the branch
|
|
|
|
|
|
/// surviving. Instead it
|
|
|
|
|
|
/// observes whether the target still exists ([`removal_target_still_present`])
|
|
|
|
|
|
/// and restores the row via [`restore_failed_removal`] only when it does, so
|
|
|
|
|
|
/// the list never shows a removal that didn't happen. The `Result` is for
|
|
|
|
|
|
/// logging.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
///
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
/// `repo` is the worktree the picker is operating from — the config source
|
|
|
|
|
|
/// for the removal hooks (see [`approved_removal_plan`]) and the target of
|
|
|
|
|
|
/// a `BranchOnly` deletion. `RemovedWorktree` removal itself is rooted at
|
|
|
|
|
|
/// `main_path` (which may differ from the picker's startup repo in bare-repo
|
|
|
|
|
|
/// setups).
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
fn do_removal(
|
|
|
|
|
|
repo: &Repository,
|
|
|
|
|
|
result: &RemoveResult,
|
|
|
|
|
|
approvals: &Approvals,
|
|
|
|
|
|
) -> anyhow::Result<()> {
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
match result {
|
|
|
|
|
|
RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path,
|
|
|
|
|
|
worktree_path,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
feat(hooks): `post-remove` reads the removed worktree's config (snapshot before removal) (#2736)
`pre-remove` already reads the worktree-being-removed's
`.config/wt.toml`, but `post-remove` read the *post-removal* working
directory's config — the primary worktree for `wt remove` / `wt step
prune`, the merge destination for `wt merge`. So a `post-remove` defined
in a feature branch's branch-local config never fired, and a
`[pre-remove]`/`[post-remove]` pair in the same `.config/wt.toml` didn't
read the same file. The only reason for the asymmetry: the worktree
ceases to exist between the two hooks.
This snapshots the removed worktree's `ProjectConfig` before deletion
(stashed on `RemoveResult::RemovedWorktree::removed_project_config`) and
threads it to the `post-remove` executor. The rule the `commands::hooks`
spec docstring now states up front: **every hook is anchored to one
worktree — the one it's *about* — and reads that worktree's
`.config/wt.toml`; when the worktree isn't on disk at read time, read a
snapshot.** Two hooks need that: `post-remove` (worktree just deleted)
and `wt switch --create`'s post-switch hooks at approval time (worktree
not created yet — reads the base ref via `git show`). Same role, two
physical sources; the spec's new "Snapshot paths" section frames them
together, and `pre-remove`/`post-remove` collapse into one table row.
## What changed
- **`RemoveResult::RemovedWorktree`** gains `removed_project_config:
Option<Box<ProjectConfig>>` (boxed — `ProjectConfig` is ~600 bytes and
would otherwise dominate the variant; clippy `large_enum_variant`).
`prepare_worktree_removal` and `finish_after_merge` populate it before
any deletion. A parse failure there is *not* propagated: the executor's
own `load_project_config()` re-reads from disk and surfaces the parse
error with full chain, and `pre-remove` aborts before `post-remove`
fires, so the `None` is invisible. (Routing the parse error through
`prepare_worktree_removal` would land it in `main.rs`'s `record_error`,
which renders only the top message — a pre-existing display gap
orthogonal to this work.)
- **`output::handlers::spawn_hooks_after_remove`** registers
`post-remove` via the new `HookAnnouncer::register_with_project_config`
using that snapshot. `post-switch` after a removal keeps loading from
`ctx.repo` (the post-removal cwd — the worktree it's *about*).
- **`commands::picker::do_removal`** (the TUI in-place removal path) was
the last hook-execution site reading the primary's config — `pre-remove`
was rooted at `main_path`, `post-remove` likewise. Now `pre-remove` is
rooted at the worktree being removed and `post-remove` uses the
snapshot, matching `wt remove`.
- **`collect_remove_hook_commands`** (the shared approval helper for `wt
remove` / `wt merge` / `wt step prune`) collects `post-remove`
per-worktree (same loop as `pre-remove`); `post-switch` keeps coming
from `primary_repo`.
- **`prepare_background_pipelines`** absorbs its single-use loading
shim: it now takes a required `Option<&ProjectConfig>`, and
`HookAnnouncer::register` (loads from `ctx.repo`) /
`register_with_project_config` (explicit snapshot) are the two ergonomic
wrappers — mirroring the approval side's `approve_or_skip` /
`approve_or_skip_with_config`.
- **Spec**: `commands::hooks` module docstring rewritten — "anchor
worktree" framing, the `pre-`/`post-remove` row merge, the "Snapshot
paths" section.
## Tests
`test_post_remove_hook_reads_removed_worktree_config` (remove.rs) — the
mirror of the existing
`test_pre_remove_hook_reads_removed_worktree_config`: a `post-remove` in
the removed worktree's branch-local `.config/wt.toml` fires correctly
*after* the worktree is gone. Verified to fail without the snapshot
wiring. Existing pre-remove / merge-teardown / prune / approval-prompt /
picker suites all pass; `wt hook pre-merge --yes` (full suite + lints +
doctests) is green.
## Follow-up (not in this PR)
`commands::picker::do_removal` is still a parallel reimplementation of
the `wt remove` pipeline (pre-remove → git removal → post-remove), ~80
lines that shadow `handle_remove_output`'s flow plus its own
`PostRemoveContext` construction. This PR makes it follow the same
config rule; routing it through `handle_remove_output` (which has a
quiet/background mode) would delete the duplication outright — a
separate, larger refactor with its own picker-test burden.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:02:47 -07:00
|
|
|
|
let main_repo = Repository::at(main_path)?;
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
let plan = approved_removal_plan(repo, main_path, worktree_path, approvals)?;
|
refactor(hooks): flatten the hook execution call chain (#3036)
A foreground hook run previously passed through four delegation layers
(`execute_hook` → `run_hooks_foreground` → `prepare_and_check` →
`prepare_sourced_steps`), with a `HookCommandSpec` parameter bundle
(four lifetime params, constructed in five places, immediately
destructured by its consumers), a trivial `lookup_hook_configs` tuple
builder, and a `BackgroundPipeline` 4-tuple packed and unpacked at every
use. This collapses the layering without changing behavior. Net −173
lines.
**Foreground path** is now `execute_hook` → `run_hooks_foreground` →
`prepare_and_check` (the work):
- `prepare_sourced_steps` merged into `prepare_and_check`, which takes
explicit parameters; `HookCommandSpec` is gone.
`check_name_filter_matched` + `count_sourced_commands` collapsed into a
`no_matching_commands_error` builder: the zero-match condition is
`!name_filters.is_empty() && result.is_empty()`, equivalent because
every step surviving the filter keeps at least one command. The dry-run
branch of `wt hook` still shares `prepare_and_check`, so the "no
commands matched" error stays in one place.
- `run_hooks_foreground` computes
`pre_hook_display_path(ctx.worktree_path)` internally; every caller
passed exactly that expression. That drops `display_path` from
`execute_hook` (and its three call sites) and deletes
`run_filtered_hook`, which had become a 1:1 forward. Plan-backed hooks
(`execute_planned_hook`) keep their explicit `display_path` since the
remove path genuinely varies between pre/post display logic.
**Background path**: `PendingPipeline` (the existing owned struct) is
the carrier from registration to spawn. `HookAnnouncer::extend` becomes
`add_groups`, `prepare_background_pipelines` inlines into `register`,
and `flush` no longer rebuilds `CommandContext`s (spawning needs only
repo/path/branch), which makes the announcer's `config` field dead;
`HookAnnouncer::new` loses that parameter at its nine call sites.
`lookup_hook_configs` is inlined at all seven sites as the two direct
`.get()` expressions.
`hook_plan.rs` changes are mechanical (import cleanup, inlined lookup,
`add_groups`); the `ApprovedHookPlan` freezing structure is untouched.
Adjacent cleanups: a redundant `UserConfig::load()` in main.rs's squash
arm (`handle_squash` loads it itself with the identical error context)
and a dead emptiness check in `run_post_hook`'s filter path (a filter
matching nothing errors in `prepare_and_check`; `prepare_and_check`'s
doc now records that `add_groups` relies on this).
Covered by the existing suite (3916 tests, no snapshot changes,
confirming announcements/errors/hints render identically). No new tests:
the refactor adds no behavior.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:10:35 -07:00
|
|
|
|
let mut announcer = HookAnnouncer::new(&main_repo, false);
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
handle_remove_output(
|
|
|
|
|
|
result,
|
|
|
|
|
|
/* foreground */ true,
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
&plan,
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
/* quiet */ true,
|
|
|
|
|
|
/* silent */ true,
|
|
|
|
|
|
&mut announcer,
|
2026-05-24 16:12:09 -07:00
|
|
|
|
BackgroundFallbackMode::Detached,
|
2026-04-18 15:03:47 -07:00
|
|
|
|
)?;
|
refactor(hooks): make HookAnnouncer the single entry point (#2484)
Builds on #2477 and #2482. Drops the `Option<&mut HookAnnouncer<'_>>`
plumbing through `commit.rs::commit`, `step_commands::handle_squash`,
`output/handlers.rs::handle_remove_output` (and its four sub-handlers),
and the multi-target prune loop in `step_commands::step_prune`. Each
function now takes `&mut HookAnnouncer<'_>` unconditionally — the
if/else around \"share with caller's announcer or self-announce\" is
gone. Standalone callers (`wt commit`, `wt step squash`, `wt remove`,
`wt step prune`, `wt switch` picker post-remove) construct a local
announcer and flush right after; multi-phase callers (`wt merge`) keep
their existing single-announcer-per-command pattern.
`run_hooks_background` is now a module-private helper of
`HookAnnouncer::flush` (was `pub(crate)`).
`hook_commands::run_post_hook` also routes through `HookAnnouncer`, so
`flush` is the only path that reaches the announce/spawn primitive.
`RemovedWorktreeOutputContext` loses its now-redundant
`show_branch_in_hooks` field — callers configure the announcer directly
when constructing it.
No behavior change: combined announce lines, log paths, `show_branch`
semantics for batch contexts (prune, multi-target remove), and exit
codes are all unchanged. Existing snapshots and the full integration
suite (3399 tests) pass; `cargo run -- hook pre-merge --yes` is green.
> _This was written by Claude Code on behalf of @max-sixty_
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-29 19:58:31 -07:00
|
|
|
|
announcer.flush()?;
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
RemoveResult::BranchOnly {
|
|
|
|
|
|
branch_name,
|
|
|
|
|
|
deletion_mode,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
if !deletion_mode.should_keep() {
|
|
|
|
|
|
let default_branch = repo.default_branch();
|
|
|
|
|
|
let target = default_branch.as_deref().unwrap_or("HEAD");
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
if let Ok(snapshot) = repo.capture_refs()
|
|
|
|
|
|
&& let Err(e) = delete_branch_if_safe(
|
refactor(git): RefSnapshot — close ambient ref-keyed cache staleness class (#2528)
## Summary
Replaces the ambient ref-keyed caches in `RepoCache` with an explicit,
point-in-time `RefSnapshot` value threaded through read paths. This
closes the bug class behind PR #2507 (post-`update-ref` integration
check seeing pre-write SHAs) — and several latent variants — by making
the caches that could go stale unrepresentable.
## Why
`RepoCache` cached ref-name → SHA in `commit_shas: DashMap<String,
String>`, plus composite caches keyed off it (`integration_reasons`,
`effective_integration_targets`, `ahead_behind`, `integration_target`
OnceCell, `tree_shas`, `resolved_refs`). When wt itself moved a ref
mid-command — most prominently `wt merge` running `git update-ref
refs/heads/<target>` to advance the local target — those caches went
stale, and any downstream read returned pre-write SHAs.
PR #2507 patched one symptom by adding `ref_is_ancestor` (a cache-bypass
helper) at the integration-check call site. That works locally; every
other ref-name read after a write was a latent bug.
The structural fix: there is no longer an ambient ref-name → SHA cache.
Callers explicitly capture a `RefSnapshot` at known points and thread it
through the read paths. After ref-mutating operations, callers capture a
fresh snapshot — the discipline lives at the small set of write
boundaries (currently one site, `finish_after_merge`), not at every
ref-name accessor.
Background analysis: `docs/dev/cache-staleness.md` (commit ba8b239)
walks through six options and the trade-offs.
## What changed
- **New `src/git/repository/ref_snapshot.rs`** — `RefSnapshot` value
type. One `for-each-ref` over local + remote refs, optional ahead/behind
batch. Cloned by value, no `OnceCell`/`Arc`. ~330 lines including unit
tests (the freshness contract test mutates a ref and re-captures).
- **Two-tier API** — `_by_sha` siblings of cached methods
(`is_ancestor_by_sha`, `merge_base_by_sha`, `branch_diff_stats_by_sha`,
`ahead_behind_by_sha`, `has_added_changes_by_sha`, `trees_match_by_sha`,
`merge_integration_probe_by_sha`, …). The persistent on-disk `sha_cache`
(already SHA-keyed) sits behind these.
- **Snapshot-taking integration path** — `compute_integration_lazy`,
`integration_reason`, `compute_integration_reason_uncached`,
`integration_targets`, `delete_branch_if_safe`,
`remove_worktree_with_cleanup` all take `&RefSnapshot`.
- **Post-write fresh-snapshot pattern** — `wt merge`'s
`finish_after_merge` captures a *fresh* snapshot AFTER
`handle_no_ff_merge`/`handle_push` runs `update-ref`. This is the
structural fix for PR #2507. The `ref_is_ancestor` workaround is
deleted.
- **`wt list` migration** — pre-skeleton phase captures one snapshot;
`TaskContext` carries it; tasks call `_by_sha` siblings via the
snapshot. No regression intended (one extra `for-each-ref` upfront, lose
per-task `rev-parse` fallbacks).
- **Cache deletions** — `commit_shas`, `tree_shas`,
`effective_integration_targets`, `integration_reasons`, `ahead_behind`,
`integration_target` OnceCell, `resolved_refs` all gone from
`RepoCache`. `LocalBranchInventory`'s priming side-effects (writes to
`commit_shas`/`resolved_refs`) removed.
- **Uncached primitives** — `rev_parse_commit`, `rev_parse_tree`,
`resolve_preferring_branch` no longer cache; consumers that need
SHA-stable lookups thread a `RefSnapshot`.
- **Persistent caches retained** — on-disk `sha_cache::*` and the
in-memory `merge_base` / `branch_diff_stats` caches keep their SHA-keyed
contract.
## Snapshot test deltas
Three `list_*_with_nonexistent_default_branch.snap` snapshots no longer
show the same-commit `_` symbol when the configured default branch is
missing — that was a false positive (no ref to compare against).
`list_warns_when_commit_details_batch_fails.snap` now embeds the
resolved SHA in the error rather than the literal `"main"` ref name
(strict improvement — the SHA is what was passed to git).
## Regression coverage
- PR #2507's regression test
(`test_remove_merged_locally_when_upstream_diverged`) passes without
`ref_is_ancestor`.
- PR #2513's
`test_list_integrated_when_merged_locally_with_upstream_diverged`
passes.
- New unit test: `RefSnapshot` capture-mutate-recapture confirms two
captures observe distinct SHAs after `update-ref`.
## Out of scope (deferred)
Both flagged by `/reviewing-code` and explicitly deferred:
- `prepare_worktree_removal` re-captures a snapshot per candidate inside
`step_prune` — N+1 `for-each-ref` calls. Threading an optional
`&RefSnapshot` would make `step_prune` reuse its entry snapshot. Affects
display consistency in unusual hook setups (a `post-remove` hook that
runs `git fetch` between candidates can desync the displayed integration
target from the safety decision); not a safety bug.
- `head_shas` cache retained. Different staleness model (per-worktree,
mutated by `commit`/`reset` rather than `update-ref`); design doc lists
it as a remaining vector. Out of scope for this PR's integration-target
focus.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` (3413 tests pass before the
merge of `origin/main`)
- [x] 1071 lib + 607 bin + 1588 integration tests pass after merging
`origin/main`
- [x] `cargo check --all-targets` clean
- [ ] CI green
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:59:41 -07:00
|
|
|
|
repo,
|
|
|
|
|
|
&snapshot,
|
|
|
|
|
|
branch_name,
|
|
|
|
|
|
target,
|
|
|
|
|
|
deletion_mode.is_force(),
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
)
|
|
|
|
|
|
{
|
|
|
|
|
|
// A safe-delete refusal is `Ok(NotDeleted)`, not an error;
|
|
|
|
|
|
// this is a genuine `git branch -D` failure. The row is
|
|
|
|
|
|
// restored anyway because the branch still exists (see
|
|
|
|
|
|
// `removal_target_still_present`) — surface the cause.
|
2026-06-25 01:02:50 -07:00
|
|
|
|
tracing::warn!(branch = %branch_name, error = %e, "picker: failed to delete branch '{branch_name}': {e:#}");
|
refactor(git): RefSnapshot — close ambient ref-keyed cache staleness class (#2528)
## Summary
Replaces the ambient ref-keyed caches in `RepoCache` with an explicit,
point-in-time `RefSnapshot` value threaded through read paths. This
closes the bug class behind PR #2507 (post-`update-ref` integration
check seeing pre-write SHAs) — and several latent variants — by making
the caches that could go stale unrepresentable.
## Why
`RepoCache` cached ref-name → SHA in `commit_shas: DashMap<String,
String>`, plus composite caches keyed off it (`integration_reasons`,
`effective_integration_targets`, `ahead_behind`, `integration_target`
OnceCell, `tree_shas`, `resolved_refs`). When wt itself moved a ref
mid-command — most prominently `wt merge` running `git update-ref
refs/heads/<target>` to advance the local target — those caches went
stale, and any downstream read returned pre-write SHAs.
PR #2507 patched one symptom by adding `ref_is_ancestor` (a cache-bypass
helper) at the integration-check call site. That works locally; every
other ref-name read after a write was a latent bug.
The structural fix: there is no longer an ambient ref-name → SHA cache.
Callers explicitly capture a `RefSnapshot` at known points and thread it
through the read paths. After ref-mutating operations, callers capture a
fresh snapshot — the discipline lives at the small set of write
boundaries (currently one site, `finish_after_merge`), not at every
ref-name accessor.
Background analysis: `docs/dev/cache-staleness.md` (commit ba8b239)
walks through six options and the trade-offs.
## What changed
- **New `src/git/repository/ref_snapshot.rs`** — `RefSnapshot` value
type. One `for-each-ref` over local + remote refs, optional ahead/behind
batch. Cloned by value, no `OnceCell`/`Arc`. ~330 lines including unit
tests (the freshness contract test mutates a ref and re-captures).
- **Two-tier API** — `_by_sha` siblings of cached methods
(`is_ancestor_by_sha`, `merge_base_by_sha`, `branch_diff_stats_by_sha`,
`ahead_behind_by_sha`, `has_added_changes_by_sha`, `trees_match_by_sha`,
`merge_integration_probe_by_sha`, …). The persistent on-disk `sha_cache`
(already SHA-keyed) sits behind these.
- **Snapshot-taking integration path** — `compute_integration_lazy`,
`integration_reason`, `compute_integration_reason_uncached`,
`integration_targets`, `delete_branch_if_safe`,
`remove_worktree_with_cleanup` all take `&RefSnapshot`.
- **Post-write fresh-snapshot pattern** — `wt merge`'s
`finish_after_merge` captures a *fresh* snapshot AFTER
`handle_no_ff_merge`/`handle_push` runs `update-ref`. This is the
structural fix for PR #2507. The `ref_is_ancestor` workaround is
deleted.
- **`wt list` migration** — pre-skeleton phase captures one snapshot;
`TaskContext` carries it; tasks call `_by_sha` siblings via the
snapshot. No regression intended (one extra `for-each-ref` upfront, lose
per-task `rev-parse` fallbacks).
- **Cache deletions** — `commit_shas`, `tree_shas`,
`effective_integration_targets`, `integration_reasons`, `ahead_behind`,
`integration_target` OnceCell, `resolved_refs` all gone from
`RepoCache`. `LocalBranchInventory`'s priming side-effects (writes to
`commit_shas`/`resolved_refs`) removed.
- **Uncached primitives** — `rev_parse_commit`, `rev_parse_tree`,
`resolve_preferring_branch` no longer cache; consumers that need
SHA-stable lookups thread a `RefSnapshot`.
- **Persistent caches retained** — on-disk `sha_cache::*` and the
in-memory `merge_base` / `branch_diff_stats` caches keep their SHA-keyed
contract.
## Snapshot test deltas
Three `list_*_with_nonexistent_default_branch.snap` snapshots no longer
show the same-commit `_` symbol when the configured default branch is
missing — that was a false positive (no ref to compare against).
`list_warns_when_commit_details_batch_fails.snap` now embeds the
resolved SHA in the error rather than the literal `"main"` ref name
(strict improvement — the SHA is what was passed to git).
## Regression coverage
- PR #2507's regression test
(`test_remove_merged_locally_when_upstream_diverged`) passes without
`ref_is_ancestor`.
- PR #2513's
`test_list_integrated_when_merged_locally_with_upstream_diverged`
passes.
- New unit test: `RefSnapshot` capture-mutate-recapture confirms two
captures observe distinct SHAs after `update-ref`.
## Out of scope (deferred)
Both flagged by `/reviewing-code` and explicitly deferred:
- `prepare_worktree_removal` re-captures a snapshot per candidate inside
`step_prune` — N+1 `for-each-ref` calls. Threading an optional
`&RefSnapshot` would make `step_prune` reuse its entry snapshot. Affects
display consistency in unusual hook setups (a `post-remove` hook that
runs `git fetch` between candidates can desync the displayed integration
target from the safety decision); not a safety bug.
- `head_shas` cache retained. Different staleness model (per-worktree,
mutated by `commit`/`reset` rather than `update-ref`); design doc lists
it as a remaining vector. Out of scope for this PR's integration-target
focus.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` (3413 tests pass before the
merge of `origin/main`)
- [x] 1071 lib + 607 bin + 1588 integration tests pass after merging
`origin/main`
- [x] `cargo check --all-targets` clean
- [ ] CI green
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:59:41 -07:00
|
|
|
|
}
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-23 12:18:42 -07:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
/// Drop the selected row and remove its target on a background thread.
|
|
|
|
|
|
///
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// For a removal that will remove the row entirely — a worktree whose branch
|
|
|
|
|
|
/// is *also* deleted (integrated, or force), or a force-deleted branch-only
|
|
|
|
|
|
/// row. A worktree removal that *keeps* its branch never reaches here; that's
|
|
|
|
|
|
/// the in-place morph ([`morph_and_remove_in_background`](Self::morph_and_remove_in_background)).
|
|
|
|
|
|
/// The `output()` token is unique per row (a `worktree-path:` path for
|
|
|
|
|
|
/// worktrees), so this drops exactly the selected row even when several
|
|
|
|
|
|
/// detached rows share the `(detached)` branch label.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// The row drops optimistically so the list stays snappy; the git work runs on
|
|
|
|
|
|
/// a background thread off skim's event loop. The dropped row is restored only
|
|
|
|
|
|
/// when the target survives — observed directly ([`removal_target_still_present`]),
|
|
|
|
|
|
/// not inferred from `do_removal`'s `Result`, which is `Err` after a successful
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// removal whose `post-remove` hook fails to render/spawn. This keeps the list
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// from showing a removal that didn't happen without ever resurrecting a row
|
|
|
|
|
|
/// for a target that's actually gone.
|
|
|
|
|
|
fn drop_and_remove_in_background(
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
&self,
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
selected_output: String,
|
|
|
|
|
|
planning_repo: Repository,
|
|
|
|
|
|
result: RemoveResult,
|
|
|
|
|
|
) {
|
|
|
|
|
|
// Capture the removed row (and its position) before dropping it: the
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// position is handed to the background thread so it can put the row back
|
|
|
|
|
|
// at its slot if the removal fails (see `restore_failed_removal`). The
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// cursor needs no separate repositioning — the caller rebuilds skim's pool
|
|
|
|
|
|
// from this shrunk list ([`resync_pool`]) without a `reload`, so `current`
|
|
|
|
|
|
// holds its index and the row that slides up into the removed slot lands
|
|
|
|
|
|
// under the cursor for free, query or no query.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
let removed = {
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
let mut items = self.items.lock().unwrap();
|
|
|
|
|
|
let removed = items
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.position(|item| item.output().as_ref() == selected_output)
|
|
|
|
|
|
.map(|pos| (Arc::clone(&items[pos]), pos));
|
|
|
|
|
|
items.retain(|item| item.output().as_ref() != selected_output);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
removed
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// A user-facing (label, noun) for the `kept` message, taken from the result
|
|
|
|
|
|
// before it moves into the background thread.
|
|
|
|
|
|
let (removal_label, removal_noun) = removal_failure_subject(&result);
|
|
|
|
|
|
|
|
|
|
|
|
let repo = planning_repo.clone();
|
|
|
|
|
|
let approvals = Arc::clone(&self.approvals);
|
|
|
|
|
|
let items = Arc::clone(&self.items);
|
|
|
|
|
|
let render_tx = Arc::clone(&self.render_tx);
|
|
|
|
|
|
let stashed_warnings = Arc::clone(&self.stashed_warnings);
|
2026-07-01 11:43:14 -07:00
|
|
|
|
let header_flash = Arc::clone(&self.header_flash);
|
fix(picker): cancel background commands when the picker exits (#3560)
## What
`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.
Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.
Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.
## Why
Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.
Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:
| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |
Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).
## Why a latch, not just a sweep
A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.
Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.
This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.
## Notes
- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
the picker stays live, so pressing Enter straight after can leave a
`git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
each call site.
- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
test is deterministic (a file marker for readiness, not a sleep).
An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.
Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.
Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
are pre-existing: the `spawn_removal` refactor re-indents the dispatch
closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
judged worth more than the metric.
- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00
|
|
|
|
spawn_removal(format!("picker-remove-{selected_output}"), move || {
|
|
|
|
|
|
if let Err(e) = Self::do_removal(&repo, &result, &approvals) {
|
|
|
|
|
|
tracing::warn!(selected_output = %selected_output, error = %e, "picker: removal of '{selected_output}' errored: {e:#}");
|
|
|
|
|
|
}
|
|
|
|
|
|
// A removal that keeps its branch never reaches here — that's the
|
|
|
|
|
|
// morph path (`morph_and_remove_in_background`). So a surviving
|
|
|
|
|
|
// target means the removal itself failed: put the row back.
|
|
|
|
|
|
if removal_target_still_present(&repo, &result)
|
|
|
|
|
|
&& let Some((item, pos)) = removed
|
|
|
|
|
|
{
|
|
|
|
|
|
restore_failed_removal(
|
|
|
|
|
|
&items,
|
|
|
|
|
|
&header_flash,
|
|
|
|
|
|
&render_tx,
|
|
|
|
|
|
&stashed_warnings,
|
|
|
|
|
|
DroppedRow {
|
|
|
|
|
|
item,
|
|
|
|
|
|
pos,
|
|
|
|
|
|
label: removal_label,
|
|
|
|
|
|
noun: removal_noun,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 11:43:14 -07:00
|
|
|
|
/// Flash a one-line message in the header for a beat (see the free
|
|
|
|
|
|
/// [`flash_header`] for the mechanism and generation guard). The synchronous
|
|
|
|
|
|
/// keep/reject paths (on skim's event loop) reach it through this method; the
|
|
|
|
|
|
/// background failure paths ([`restore_failed_removal`], [`revert_morph`]) call
|
|
|
|
|
|
/// the free function directly, off the event loop.
|
2026-06-30 20:56:08 -07:00
|
|
|
|
fn flash_header(&self, message: String) {
|
2026-07-01 11:43:14 -07:00
|
|
|
|
flash_header(&self.header_flash, &self.render_tx, message);
|
2026-06-30 20:56:08 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// Keep the selected row in place and explain why its target wasn't removed.
|
|
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Called from [`apply`](Self::apply) when [`removal_will_remove_target`]
|
|
|
|
|
|
/// predicts the removal would keep the target — a branch-only row whose branch
|
|
|
|
|
|
/// is unmerged, which `SafeDelete` declines to delete (data safety). Deciding
|
|
|
|
|
|
/// this up front from `prepare_removal`'s already-computed integration check
|
|
|
|
|
|
/// means the row never drops (no flicker) and no background `do_removal` runs
|
2026-06-30 20:56:08 -07:00
|
|
|
|
/// for a no-op. The row stays in its slot under the (un-reset) cursor, so this
|
|
|
|
|
|
/// flashes a terse "branch is unmerged" line in the header (the *why*, where the
|
|
|
|
|
|
/// user is looking) and stashes the canonical "retained; unmerged" info + hint
|
|
|
|
|
|
/// pair `wt remove` itself prints (see `print_retained_unmerged_branch`), deduped
|
|
|
|
|
|
/// and drained to stderr when the picker exits. (This is a by-design retain, not
|
|
|
|
|
|
/// a failure — distinct from [`restore_failed_removal`]'s `kept … could not
|
|
|
|
|
|
/// remove it` warning.)
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn keep_unremovable_row(&self, branch_name: &str) {
|
2026-06-24 23:53:52 -07:00
|
|
|
|
// The canonical "retained; unmerged" info + hint `wt remove` prints,
|
|
|
|
|
|
// shared so the picker copy can't drift (see
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// `stash_retained_unmerged_branch`). Taking the branch name (not the whole
|
|
|
|
|
|
// `RemoveResult`) makes it unrepresentable for this keep path to be handed
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// a `RemovedWorktree`, which always removes — see the dispatch in
|
|
|
|
|
|
// [`apply`](Self::apply) and [`removal_will_remove_target`].
|
2026-06-30 20:56:08 -07:00
|
|
|
|
// A by-design retain, not a failure — info (○), matching the canonical
|
|
|
|
|
|
// `info_message` this path stashes (see `stash_retained_unmerged_branch`).
|
|
|
|
|
|
self.flash_header(
|
|
|
|
|
|
info_message(cformat!("Kept <bold>{branch_name}</> — branch is unmerged")).to_string(),
|
|
|
|
|
|
);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
stash_retained_unmerged_branch(&self.stashed_warnings, branch_name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Keep the current worktree's row in place and explain why the picker won't
|
|
|
|
|
|
/// remove it.
|
|
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Called from [`apply`](Self::apply) when [`removal_targets_current_worktree`]
|
|
|
|
|
|
/// is true — alt-x on the worktree the picker was launched from. Removing it
|
|
|
|
|
|
/// would have to switch the shell elsewhere first (see
|
|
|
|
|
|
/// `removal_targets_current_worktree` for why that's disruptive mid-picker), so
|
2026-06-30 20:56:08 -07:00
|
|
|
|
/// the row stays put: this flashes a terse "current worktree" line in the header
|
|
|
|
|
|
/// and stashes the fuller hint to switch away first, drained to stderr when the
|
|
|
|
|
|
/// picker exits. The row never drops and no `do_removal` runs, so this is the
|
|
|
|
|
|
/// only removal path that never reaches a background thread.
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn keep_current_worktree_row(&self) {
|
2026-06-30 20:56:08 -07:00
|
|
|
|
// A by-design decline, not a failure — info (○), matching the canonical
|
|
|
|
|
|
// `info_message` this path stashes (see `stash_current_worktree_hint`).
|
|
|
|
|
|
self.flash_header(info_message("Can't remove the current worktree").to_string());
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
stash_current_worktree_hint(&self.stashed_warnings);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Morph the selected worktree row into a `/ branch` row in place, then remove
|
|
|
|
|
|
/// the worktree on a background thread.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// For a `RemovedWorktree` removal that [`worktree_removal_keeps_branch`]
|
|
|
|
|
|
/// predicts will keep its (unmerged) branch. The row never leaves its slot:
|
|
|
|
|
|
/// the morph rewrites the row's shared `rendered` line to the branch line
|
|
|
|
|
|
/// (rendered on the live layout — gutter `+` → `/`, path blank), flips the
|
|
|
|
|
|
/// row's [`morphed`](items::LocalCheckout::morphed) flag (so `output()`
|
|
|
|
|
|
/// becomes the branch token), dims the `working_tree` preview tab (no worktree
|
|
|
|
|
|
/// left to diff), and re-keys the row's `alt-y`/`alt-o` shortcut entry to the
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// branch token. skim repaints just that row, and the (un-reset) cursor holds
|
|
|
|
|
|
/// the same slot — no teleport, no reset.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// The morph is optimistic, like the drop path. The background thread runs the
|
|
|
|
|
|
/// git removal and, only if the worktree unexpectedly survives
|
|
|
|
|
|
/// ([`removal_target_still_present`] — a clean-check race, a locked dir, a
|
|
|
|
|
|
/// failing `pre-remove` hook), reverts the morph back to the worktree row via
|
|
|
|
|
|
/// [`revert_morph`] and surfaces why. (The branch can't flip integrated in the
|
|
|
|
|
|
/// millisecond between the prediction and the delete, so the only realistic
|
|
|
|
|
|
/// failure is the worktree removal itself.)
|
|
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Returns [`RemovalEffect::Morphed`] on the in-place morph, or
|
|
|
|
|
|
/// [`RemovalEffect::Dropped`] when it falls back to
|
|
|
|
|
|
/// [`drop_and_remove_in_background`](Self::drop_and_remove_in_background) —
|
|
|
|
|
|
/// the row carries no [`MorphHandle`](items::MorphHandle) or the layout hasn't
|
|
|
|
|
|
/// landed, so the worktree still removes but the row drops instead of morphing.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
fn morph_and_remove_in_background(
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
&self,
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
selected_output: String,
|
|
|
|
|
|
branch: String,
|
|
|
|
|
|
planning_repo: Repository,
|
|
|
|
|
|
result: RemoveResult,
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
) -> RemovalEffect {
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// Gather the row's shared morph handles and render the branch line on the
|
|
|
|
|
|
// live layout. Any gap (row not morphable, layout not yet handed over)
|
|
|
|
|
|
// means no clean in-place morph — drop the row instead, same end state.
|
|
|
|
|
|
let default_branch = self.repo.default_branch();
|
|
|
|
|
|
let prepared = {
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let table = self.shortcut_table.lock().unwrap();
|
|
|
|
|
|
let layout = self.layout_slot.lock().unwrap();
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
match (
|
|
|
|
|
|
table.get(&selected_output).and_then(|d| d.morph.as_ref()),
|
|
|
|
|
|
layout.as_ref(),
|
|
|
|
|
|
) {
|
|
|
|
|
|
(Some(handle), Some(layout)) => {
|
|
|
|
|
|
let (branch_line, branch_local) =
|
|
|
|
|
|
build_morph_branch_row(layout, &handle.item, default_branch.as_deref());
|
|
|
|
|
|
Some(MorphSlots {
|
|
|
|
|
|
rendered: Arc::clone(&handle.rendered),
|
|
|
|
|
|
morphed: Arc::clone(&handle.morphed),
|
|
|
|
|
|
local_content: Arc::clone(&handle.local_content),
|
|
|
|
|
|
branch_line,
|
|
|
|
|
|
branch_local,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let Some(slots) = prepared else {
|
|
|
|
|
|
self.drop_and_remove_in_background(selected_output, planning_repo, result);
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
return RemovalEffect::Dropped;
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Snapshot the pre-morph display for the revert, then apply the morph.
|
|
|
|
|
|
let original_rendered = slots.rendered.lock().unwrap().clone();
|
|
|
|
|
|
let original_local = *slots.local_content.lock().unwrap();
|
|
|
|
|
|
*slots.rendered.lock().unwrap() = slots.branch_line;
|
|
|
|
|
|
slots.morphed.store(true, Ordering::Relaxed);
|
|
|
|
|
|
*slots.local_content.lock().unwrap() = slots.branch_local;
|
|
|
|
|
|
|
|
|
|
|
|
// Re-key the `alt-y`/`alt-o` lookup to the branch token (the row's new
|
|
|
|
|
|
// `output()`); the revert moves it back.
|
2026-06-24 23:53:52 -07:00
|
|
|
|
{
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let mut table = self.shortcut_table.lock().unwrap();
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
if let Some(data) = table.remove(&selected_output) {
|
|
|
|
|
|
table.insert(branch.clone(), data);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
let repo = planning_repo.clone();
|
|
|
|
|
|
let approvals = Arc::clone(&self.approvals);
|
|
|
|
|
|
let render_tx = Arc::clone(&self.render_tx);
|
|
|
|
|
|
let stashed_warnings = Arc::clone(&self.stashed_warnings);
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let shortcut_table = Arc::clone(&self.shortcut_table);
|
2026-07-01 11:43:14 -07:00
|
|
|
|
let header_flash = Arc::clone(&self.header_flash);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
let revert = MorphRevert {
|
|
|
|
|
|
rendered: slots.rendered,
|
|
|
|
|
|
original_rendered,
|
|
|
|
|
|
morphed: slots.morphed,
|
|
|
|
|
|
local_content: slots.local_content,
|
|
|
|
|
|
original_local,
|
|
|
|
|
|
shortcut_table,
|
|
|
|
|
|
branch_token: branch.clone(),
|
|
|
|
|
|
worktree_token: selected_output.clone(),
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
};
|
fix(picker): cancel background commands when the picker exits (#3560)
## What
`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.
Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.
Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.
## Why
Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.
Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:
| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |
Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).
## Why a latch, not just a sweep
A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.
Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.
This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.
## Notes
- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
the picker stays live, so pressing Enter straight after can leave a
`git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
each call site.
- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
test is deterministic (a file marker for readiness, not a sleep).
An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.
Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.
Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
are pre-existing: the `spawn_removal` refactor re-indents the dispatch
closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
judged worth more than the metric.
- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00
|
|
|
|
spawn_removal(format!("picker-morph-{branch}"), move || {
|
|
|
|
|
|
if let Err(e) = Self::do_removal(&repo, &result, &approvals) {
|
|
|
|
|
|
tracing::warn!(branch = %branch, error = %e, "picker: removal of '{branch}' worktree errored: {e:#}");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Only the worktree removal can realistically fail here; if it did,
|
|
|
|
|
|
// the worktree dir survives — undo the morph and say so.
|
|
|
|
|
|
if removal_target_still_present(&repo, &result) {
|
|
|
|
|
|
revert_morph(revert, &header_flash, &stashed_warnings, &render_tx);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
RemovalEffect::Morphed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Run the `alt-x` removal dispatch for the selected row.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Decides up front, from `prepare_removal`'s already-computed result, what the
|
|
|
|
|
|
/// removal does to the row, mutates the picker's row list / shared row state
|
|
|
|
|
|
/// accordingly, and kicks off the background git work. Returns the
|
|
|
|
|
|
/// [`RemovalEffect`] so the keybinding callback can refresh skim's view:
|
|
|
|
|
|
/// - targets the current worktree → keep it (removing the worktree you're
|
|
|
|
|
|
/// standing in has to switch you away first, which the picker declines);
|
|
|
|
|
|
/// - keeps its (unmerged) branch → morph to `/ branch` in place;
|
|
|
|
|
|
/// - removes the target → drop the row;
|
|
|
|
|
|
/// - branch-only row whose branch is unmerged → stays put, explained.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Runs on skim's event loop (the `alt-x` keybinding callback), so the row
|
|
|
|
|
|
/// mutation and the caller's pool rebuild ([`resync_pool`]) are atomic from
|
|
|
|
|
|
/// skim's view — no `reload`, so the cursor never resets. The `~15-20ms`
|
|
|
|
|
|
/// `prepare_removal` git work is the same cost the old `reload`-time dispatch
|
|
|
|
|
|
/// paid; the actual worktree/branch deletion is deferred to a background thread.
|
|
|
|
|
|
fn apply(&self, selected_output: String) -> RemovalEffect {
|
|
|
|
|
|
let Some(removal_target) = PickerRemovalTarget::from_signal(&selected_output) else {
|
|
|
|
|
|
return RemovalEffect::Kept;
|
|
|
|
|
|
};
|
|
|
|
|
|
match self.prepare_removal(&removal_target) {
|
|
|
|
|
|
Ok((planning_repo, result)) => {
|
|
|
|
|
|
if removal_targets_current_worktree(&result) {
|
|
|
|
|
|
self.keep_current_worktree_row();
|
|
|
|
|
|
RemovalEffect::Kept
|
|
|
|
|
|
} else if let Some(branch) = worktree_removal_keeps_branch(&planning_repo, &result)
|
|
|
|
|
|
{
|
|
|
|
|
|
self.morph_and_remove_in_background(
|
|
|
|
|
|
selected_output,
|
|
|
|
|
|
branch,
|
|
|
|
|
|
planning_repo,
|
|
|
|
|
|
result,
|
|
|
|
|
|
)
|
|
|
|
|
|
} else if removal_will_remove_target(&result) {
|
|
|
|
|
|
self.drop_and_remove_in_background(selected_output, planning_repo, result);
|
|
|
|
|
|
RemovalEffect::Dropped
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// The only non-removing outcome: `removal_will_remove_target`
|
|
|
|
|
|
// returns false solely for an unmerged `BranchOnly` row (a
|
|
|
|
|
|
// `RemovedWorktree` always removes, so it never reaches here), so
|
|
|
|
|
|
// this arm is always that row — keep it, explained.
|
|
|
|
|
|
// `keep_unremovable_row` taking the branch name — not the whole
|
|
|
|
|
|
// result — keeps that narrowing at the type level.
|
|
|
|
|
|
if let RemoveResult::BranchOnly { branch_name, .. } = &result {
|
|
|
|
|
|
self.keep_unremovable_row(branch_name);
|
|
|
|
|
|
}
|
|
|
|
|
|
RemovalEffect::Kept
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::info!(selected_output = %selected_output, error = %e, "picker: cannot remove '{selected_output}': {e:#}");
|
|
|
|
|
|
// The target can't be removed — the main worktree, a dirty
|
|
|
|
|
|
// worktree, a lock. Surface the *same* diagnostic `wt remove` prints
|
|
|
|
|
|
// (drained to stderr on exit) instead of swallowing it, so alt-x
|
|
|
|
|
|
// isn't a silent dead keypress. Nothing was removed, so the row
|
|
|
|
|
|
// stays under the (un-reset) cursor.
|
|
|
|
|
|
if let Some(diagnostic) = e.render_diagnostic() {
|
|
|
|
|
|
let mut stashed = self.stashed_warnings.lock().unwrap();
|
|
|
|
|
|
if !stashed.contains(&diagnostic) {
|
|
|
|
|
|
stashed.push(diagnostic);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-30 20:56:08 -07:00
|
|
|
|
// Flash the terse headline in the header too, so the *why* lands
|
|
|
|
|
|
// at alt-x time and not only when the stash drains on exit. Unlike
|
|
|
|
|
|
// the keep paths (by-design retains → info ○), this arm is a genuine
|
|
|
|
|
|
// rejection — error (✗), matching the `render_diagnostic` error this
|
|
|
|
|
|
// path stashes. `to_string()` is the typed error's short single-line
|
|
|
|
|
|
// label (ANSI-stripped); take its first line so a multi-line chain
|
|
|
|
|
|
// can't smear the header.
|
|
|
|
|
|
let headline = e.to_string();
|
|
|
|
|
|
let headline = headline.lines().next().unwrap_or(&headline);
|
|
|
|
|
|
self.flash_header(error_message(headline).to_string());
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
RemovalEffect::Kept
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 11:43:14 -07:00
|
|
|
|
/// Flash a one-line message in the header for a beat, then clear it so the column
|
|
|
|
|
|
/// labels return. The slot is shared with the header item (see [`items::HeaderFlash`]),
|
|
|
|
|
|
/// so this sets it, repaints, and spawns a short-lived timer that clears it and
|
|
|
|
|
|
/// repaints again. The timer's `clear_if_current` is generation-guarded, so a second
|
|
|
|
|
|
/// flash arriving mid-beat replaces this one and isn't wiped early by this timer.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A no-op until skim's `render_tx` is published — but `alt-x` can only fire once the
|
|
|
|
|
|
/// TUI is showing rows, so it's always live by the time a flash path calls this.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Every alt-x "couldn't remove" reason reaches the header through here, so the
|
|
|
|
|
|
/// message surfaces immediately rather than only when the stash drains on exit:
|
|
|
|
|
|
/// [`AltXRemover::flash_header`] (the synchronous keep/reject paths, on skim's event
|
|
|
|
|
|
/// loop) and the background failure paths ([`restore_failed_removal`],
|
|
|
|
|
|
/// [`revert_morph`], off the event loop) share this one mechanism.
|
|
|
|
|
|
fn flash_header(
|
|
|
|
|
|
header_flash: &Arc<items::HeaderFlash>,
|
|
|
|
|
|
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
|
|
|
|
|
|
message: String,
|
|
|
|
|
|
) {
|
|
|
|
|
|
let Some(tx) = render_tx.get() else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
let generation = header_flash.set(message);
|
|
|
|
|
|
let _ = tx.try_send(Event::Render);
|
|
|
|
|
|
|
|
|
|
|
|
let header_flash = Arc::clone(header_flash);
|
|
|
|
|
|
let render_tx = Arc::clone(render_tx);
|
|
|
|
|
|
let _ = std::thread::Builder::new()
|
|
|
|
|
|
.name("picker-header-flash".into())
|
|
|
|
|
|
.spawn(move || {
|
|
|
|
|
|
std::thread::sleep(HEADER_FLASH_DURATION);
|
|
|
|
|
|
header_flash.clear_if_current(generation);
|
|
|
|
|
|
if let Some(tx) = render_tx.get() {
|
|
|
|
|
|
let _ = tx.try_send(Event::Render);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// The row's shared display slots plus the pre-rendered branch line a morph
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// swaps in (see [`AltXRemover::morph_and_remove_in_background`]).
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
struct MorphSlots {
|
|
|
|
|
|
rendered: Arc<Mutex<String>>,
|
|
|
|
|
|
morphed: Arc<AtomicBool>,
|
|
|
|
|
|
local_content: LocalContentSlot,
|
|
|
|
|
|
branch_line: String,
|
|
|
|
|
|
branch_local: LocalContent,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Everything the background thread needs to undo a morph when the worktree
|
|
|
|
|
|
/// removal failed (see [`revert_morph`]).
|
|
|
|
|
|
struct MorphRevert {
|
|
|
|
|
|
rendered: Arc<Mutex<String>>,
|
|
|
|
|
|
original_rendered: String,
|
|
|
|
|
|
morphed: Arc<AtomicBool>,
|
|
|
|
|
|
local_content: LocalContentSlot,
|
|
|
|
|
|
original_local: LocalContent,
|
|
|
|
|
|
shortcut_table: ShortcutTable,
|
|
|
|
|
|
/// The branch token the morph re-keyed the shortcut entry to.
|
|
|
|
|
|
branch_token: String,
|
|
|
|
|
|
/// The worktree-path token the entry is keyed under before (and after) morph.
|
|
|
|
|
|
worktree_token: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build the `/ branch` row a kept-branch `alt-x` morph swaps in — the rendered
|
|
|
|
|
|
/// line (on the picker's live `layout`, the same grid the worktree rows use) and
|
|
|
|
|
|
/// the diff-content signals for its preview tabs.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Clones the worktree row's model and demotes it to a local branch: `kind` →
|
|
|
|
|
|
/// `Branch` blanks the path and worktree-status columns and switches the gutter
|
|
|
|
|
|
/// to `/`, while counts / age / message carry over unchanged (the branch keeps
|
|
|
|
|
|
/// the worktree's HEAD). Status symbols are reset and recomputed for the branch
|
|
|
|
|
|
/// kind — `refresh_status_symbols` only fills empty slots, so the worktree's must
|
|
|
|
|
|
/// be cleared first. The [`LocalContent`] is read off the demoted item, so its
|
|
|
|
|
|
/// `working_tree` signal resolves empty (no worktree to diff) and the
|
|
|
|
|
|
/// `working_tree` preview tab dims. OSC 8 hyperlinks are stripped to match the
|
|
|
|
|
|
/// rows the handler builds (skim's pipeline mangles them).
|
|
|
|
|
|
fn build_morph_branch_row(
|
|
|
|
|
|
layout: &crate::commands::list::layout::LayoutConfig,
|
|
|
|
|
|
worktree_item: &ListItem,
|
|
|
|
|
|
default_branch: Option<&str>,
|
|
|
|
|
|
) -> (String, LocalContent) {
|
|
|
|
|
|
let mut branch_item = worktree_item.clone();
|
|
|
|
|
|
branch_item.kind = ItemKind::Branch(BranchScope::Local);
|
|
|
|
|
|
branch_item.status_symbols = Default::default();
|
|
|
|
|
|
branch_item.refresh_status_symbols(default_branch);
|
|
|
|
|
|
let line = strip_osc8_hyperlinks(
|
|
|
|
|
|
&layout
|
|
|
|
|
|
.render_list_item_line(&branch_item, PLACEHOLDER)
|
|
|
|
|
|
.render(),
|
|
|
|
|
|
);
|
|
|
|
|
|
(line, LocalContent::from_item(&branch_item))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Undo a morph after the worktree removal failed, restoring the worktree row in
|
|
|
|
|
|
/// place and explaining why it didn't go away.
|
|
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// The mirror of [`AltXRemover::morph_and_remove_in_background`]'s apply
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// step: restore the row's pre-morph display, clear the
|
|
|
|
|
|
/// [`morphed`](items::LocalCheckout::morphed) flag (so `output()` is the
|
|
|
|
|
|
/// worktree token again), restore the diff-content slot, and move the
|
|
|
|
|
|
/// `alt-y`/`alt-o` shortcut entry back to the worktree token. The row never left
|
2026-07-01 11:43:14 -07:00
|
|
|
|
/// its slot, so [`flash_header`]'s repaint re-shows it — no reload, no cursor move
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// (unlike [`restore_failed_removal`], which re-inserts a dropped row). The
|
2026-07-01 11:43:14 -07:00
|
|
|
|
/// `kept … could not remove it` reason lands twice: flashed in the header now, and
|
|
|
|
|
|
/// drained to stderr when the picker exits.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
fn revert_morph(
|
|
|
|
|
|
revert: MorphRevert,
|
2026-07-01 11:43:14 -07:00
|
|
|
|
header_flash: &Arc<items::HeaderFlash>,
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
stashed_warnings: &Mutex<Vec<String>>,
|
2026-07-01 11:43:14 -07:00
|
|
|
|
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
) {
|
|
|
|
|
|
let MorphRevert {
|
|
|
|
|
|
rendered,
|
|
|
|
|
|
original_rendered,
|
|
|
|
|
|
morphed,
|
|
|
|
|
|
local_content,
|
|
|
|
|
|
original_local,
|
|
|
|
|
|
shortcut_table,
|
|
|
|
|
|
branch_token,
|
|
|
|
|
|
worktree_token,
|
|
|
|
|
|
} = revert;
|
|
|
|
|
|
|
|
|
|
|
|
*rendered.lock().unwrap() = original_rendered;
|
|
|
|
|
|
morphed.store(false, Ordering::Relaxed);
|
|
|
|
|
|
*local_content.lock().unwrap() = original_local;
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut table = shortcut_table.lock().unwrap();
|
|
|
|
|
|
if let Some(data) = table.remove(&branch_token) {
|
|
|
|
|
|
table.insert(worktree_token, data);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
2026-07-01 11:43:14 -07:00
|
|
|
|
// Surface the "couldn't remove" reason two ways, like the drop path
|
|
|
|
|
|
// ([`restore_failed_removal`]): flash it in the header now (the row un-morphed
|
|
|
|
|
|
// under the cursor, so the *why* lands where the user is looking) and stash the
|
|
|
|
|
|
// same line to drain to stderr on exit. A genuine failure — the removal was
|
|
|
|
|
|
// attempted and the worktree survived — so warning (▲), not the keep paths'
|
|
|
|
|
|
// by-design info (○). `flash_header`'s repaint also re-shows the reverted row,
|
|
|
|
|
|
// so no separate `Event::Render` is needed.
|
|
|
|
|
|
let warning = warning_message(cformat!(
|
|
|
|
|
|
"Kept <bold>{branch_token}</> worktree — could not remove it"
|
|
|
|
|
|
))
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
flash_header(header_flash, render_tx, warning.clone());
|
|
|
|
|
|
stashed_warnings.lock().unwrap().push(warning);
|
2026-03-23 12:18:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
/// Number of leading non-selectable header rows the picker streams (the single
|
|
|
|
|
|
/// `HeaderSkimItem`). The skim options pass this to `.header_lines(...)`: skim
|
|
|
|
|
|
/// reserves these from the item pool into its own Header widget, so `item_list`
|
|
|
|
|
|
/// — what the cursor moves over — holds data rows only, indexed from 0.
|
|
|
|
|
|
const PICKER_HEADER_ROWS: usize = 1;
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Rebuild skim's item pool from the picker's row list and restart the matcher,
|
|
|
|
|
|
/// **synchronously**, so the cursor holds its slot across an `alt-x` removal.
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// This is the picker's replacement for skim's `reload`. `reload` clears the pool
|
|
|
|
|
|
/// and restarts the matcher *before* the reader streams the new rows in, so the
|
|
|
|
|
|
/// matcher runs once against the empty pool, `Replace`s `item_list` with nothing,
|
|
|
|
|
|
/// and skim's render clamp (`items.is_empty() → current = 0`) snaps the cursor to
|
|
|
|
|
|
/// the top — the flash. Filling the pool here, before `restart_matcher` runs the
|
|
|
|
|
|
/// matcher (which is async, on the matcher thread pool), means the matcher only
|
|
|
|
|
|
/// ever sees the post-removal list — never empty — so `current` is preserved
|
|
|
|
|
|
/// (clamped to the shrunk list) and the row that slid into the removed slot lands
|
|
|
|
|
|
/// under the cursor. No reposition, no flash. The active query still applies
|
|
|
|
|
|
/// (`restart_matcher` re-filters with the current input), so this is correct under
|
|
|
|
|
|
/// a fuzzy filter too: the cursor's filtered-list index holds.
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// `items` carries the leading `HeaderSkimItem`, which `append` re-reserves as
|
|
|
|
|
|
/// the non-selectable header (`header_lines(1)`), matching the initial stream.
|
|
|
|
|
|
fn resync_pool(app: &mut skim::tui::App, items: &Arc<Mutex<Vec<Arc<dyn SkimItem>>>>) {
|
|
|
|
|
|
let batch: Vec<Arc<dyn SkimItem>> = items.lock().unwrap().iter().map(Arc::clone).collect();
|
|
|
|
|
|
app.item_pool.clear();
|
|
|
|
|
|
app.item_pool.append(batch);
|
|
|
|
|
|
app.restart_matcher(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A skim `Custom` action that runs [`resync_pool`] on the event loop.
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Both alt-x sites queue it. The keybinding callback returns it for the drop path
|
|
|
|
|
|
/// — skim processes a callback's returned events (then a Render) in order, so the
|
|
|
|
|
|
/// queued resync rebuilds the pool before any repaint, equivalent to an inline
|
|
|
|
|
|
/// rebuild. A background removal that fails ([`restore_failed_removal`]) re-inserts
|
|
|
|
|
|
/// the row from off the event loop and has no `App`, so it queues this through
|
|
|
|
|
|
/// skim's event sender to re-show the restored row. Sharing one action keeps the
|
|
|
|
|
|
/// pool-rebuild logic in a single place. The re-inserted row lands at the removed
|
|
|
|
|
|
/// row's old slot — which is exactly where `current` sits after the drop slid the
|
|
|
|
|
|
/// successor up — so the cursor lands back on it for free.
|
|
|
|
|
|
fn resync_pool_action(items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>) -> Action {
|
|
|
|
|
|
Action::Custom(ActionCallback::new_sync(
|
|
|
|
|
|
move |app| -> Result<Vec<Event>, Box<dyn std::error::Error + Send + Sync>> {
|
|
|
|
|
|
resync_pool(app, &items);
|
|
|
|
|
|
Ok(Vec::new())
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Consecutive "matcher settled on the resynced pool" observations
|
|
|
|
|
|
/// [`run_preview_when_settled`] waits out before firing the preview. The matcher
|
|
|
|
|
|
/// writes its result, then a later render applies the `Replace` into `item_list`
|
|
|
|
|
|
/// and clamps the cursor — so `item_list` lags the matcher by a render. Each
|
|
|
|
|
|
/// re-arm queues a Render (skim appends one after every action), so three settled
|
|
|
|
|
|
/// observations guarantee the reloaded rows (and the cursor clamp) are in before
|
|
|
|
|
|
/// the preview fires.
|
|
|
|
|
|
const PREVIEW_SETTLED_RENDERS: usize = 3;
|
|
|
|
|
|
|
|
|
|
|
|
/// Hard backstop on [`run_preview_when_settled`] re-arms, far above the handful a
|
|
|
|
|
|
/// normal resync needs. The settled check is the real stop condition; this only
|
|
|
|
|
|
/// guards an unforeseen never-settles state (e.g. a resync that empties the pool).
|
|
|
|
|
|
const PREVIEW_MAX_ATTEMPTS: usize = 1000;
|
|
|
|
|
|
|
|
|
|
|
|
/// A skim `Custom` action that fires [`Event::RunPreview`] once the resynced pool's
|
|
|
|
|
|
/// matcher has settled, refreshing the preview for the row the cursor landed on
|
|
|
|
|
|
/// after an `alt-x` drop.
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// skim auto-refreshes the preview across a matcher `Replace` only when the
|
|
|
|
|
|
/// selected row's `text()` changes (`ItemList`'s `on_selection_changed`). That
|
|
|
|
|
|
/// covers a middle-row drop — a successor slides under the cursor — but not the
|
|
|
|
|
|
/// *last* row: `current` is briefly out of range at the `Replace` render, so the
|
|
|
|
|
|
/// selection reads empty and the clamp then lands it on the new last row with no
|
|
|
|
|
|
/// text change to detect, leaving the pane showing the removed row's preview. This
|
|
|
|
|
|
/// fires the missing `RunPreview` (no cursor move — the resync already landed it).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// It re-arms until the matcher has settled on the resynced pool — stopped, the
|
|
|
|
|
|
/// pool non-empty, every item taken — for [`PREVIEW_SETTLED_RENDERS`] consecutive
|
|
|
|
|
|
/// checks; firing earlier would preview the pre-`Replace` (removed) row. A drop
|
|
|
|
|
|
/// that empties the filtered list settles the same way and previews nothing.
|
|
|
|
|
|
fn run_preview_when_settled(
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
attempts: Arc<AtomicUsize>,
|
|
|
|
|
|
settled_streak: Arc<AtomicUsize>,
|
|
|
|
|
|
) -> Action {
|
|
|
|
|
|
Action::Custom(ActionCallback::new_sync(
|
|
|
|
|
|
move |app| -> Result<Vec<Event>, Box<dyn std::error::Error + Send + Sync>> {
|
|
|
|
|
|
let matcher_settled = app.matcher_control.stopped()
|
|
|
|
|
|
&& !app.item_pool.is_empty()
|
|
|
|
|
|
&& app.item_pool.num_not_taken() == 0;
|
|
|
|
|
|
let streak = if matcher_settled {
|
|
|
|
|
|
settled_streak.fetch_add(1, Ordering::Relaxed) + 1
|
|
|
|
|
|
} else {
|
|
|
|
|
|
settled_streak.store(0, Ordering::Relaxed);
|
|
|
|
|
|
0
|
|
|
|
|
|
};
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
if streak < PREVIEW_SETTLED_RENDERS
|
|
|
|
|
|
&& attempts.fetch_add(1, Ordering::Relaxed) < PREVIEW_MAX_ATTEMPTS
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
{
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
return Ok(vec![Event::Action(run_preview_when_settled(
|
|
|
|
|
|
Arc::clone(&attempts),
|
|
|
|
|
|
Arc::clone(&settled_streak),
|
|
|
|
|
|
))]);
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
}
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
Ok(vec![Event::RunPreview])
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// A removal's user-facing subject for the `kept` warning: a `(label, noun)`
|
|
|
|
|
|
/// pair where `noun` is `worktree` or `branch` and `label` is the branch name
|
|
|
|
|
|
/// (or the worktree's display path for a detached worktree). Computed before the
|
|
|
|
|
|
/// `RemoveResult` moves into the background thread; surfaced by
|
|
|
|
|
|
/// [`restore_failed_removal`].
|
|
|
|
|
|
fn removal_failure_subject(result: &RemoveResult) -> (String, &'static str) {
|
|
|
|
|
|
match result {
|
|
|
|
|
|
RemoveResult::RemovedWorktree {
|
|
|
|
|
|
branch_name: Some(branch),
|
|
|
|
|
|
..
|
|
|
|
|
|
} => (branch.clone(), "worktree"),
|
|
|
|
|
|
RemoveResult::RemovedWorktree { worktree_path, .. } => {
|
|
|
|
|
|
(format_path_for_display(worktree_path), "worktree")
|
|
|
|
|
|
}
|
|
|
|
|
|
RemoveResult::BranchOnly { branch_name, .. } => (branch_name.clone(), "branch"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether `do_removal` will actually remove the target — predicted up front from
|
|
|
|
|
|
/// `prepare_removal`'s already-computed [`RemoveResult`], before the row is
|
|
|
|
|
|
/// dropped. The dual of [`removal_target_still_present`]: this decides whether to
|
|
|
|
|
|
/// drop the row, that confirms the drop afterward.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A `RemovedWorktree` result has passed `ensure_clean` (Phase 5 of
|
|
|
|
|
|
/// `prepare_worktree_removal`), so the worktree removes — the only failures left
|
|
|
|
|
|
/// are async and rare (a clean-check race, a failing approved `pre-remove` hook),
|
|
|
|
|
|
/// which the background restore still catches. A `BranchOnly` result deletes only
|
|
|
|
|
|
/// when `delete_branch_if_safe` would: not `Keep` mode, and either force or an
|
|
|
|
|
|
/// integrated branch (the `integration_reason` here is computed from the *same*
|
|
|
|
|
|
/// `Repository::integration_reason` the later delete consults, so they can't
|
|
|
|
|
|
/// drift). An unmerged branch-only row is thus kept, and predicting it here means
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// it never drops (no flicker) — see [`AltXRemover::keep_unremovable_row`].
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
fn removal_will_remove_target(result: &RemoveResult) -> bool {
|
|
|
|
|
|
match result {
|
|
|
|
|
|
RemoveResult::RemovedWorktree { .. } => true,
|
|
|
|
|
|
RemoveResult::BranchOnly {
|
|
|
|
|
|
deletion_mode,
|
|
|
|
|
|
integration_reason,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
!deletion_mode.should_keep()
|
|
|
|
|
|
&& (deletion_mode.is_force() || integration_reason.is_some())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// Whether the row's target is the worktree the picker was launched from — the
|
|
|
|
|
|
/// `changed_directory` flag `prepare_worktree_removal` sets when the removed
|
|
|
|
|
|
/// worktree is the caller's own.
|
|
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// The picker declines this case (see [`AltXRemover::keep_current_worktree_row`]):
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// removing the current worktree would have to cd the shell elsewhere first, and
|
|
|
|
|
|
/// that switch drags in `post-switch` hooks streaming into the picker, an empty
|
|
|
|
|
|
/// placeholder directory swapped under the cursor mid-render, and a directory
|
|
|
|
|
|
/// change the picker can't cleanly reflect. Switching away (Enter) and then
|
|
|
|
|
|
/// removing the now-non-current row is the clean path, so alt-x on the current
|
|
|
|
|
|
/// worktree keeps the row and explains. `BranchOnly` rows have no worktree to be
|
|
|
|
|
|
/// standing in, so this is always `false` for them.
|
|
|
|
|
|
fn removal_targets_current_worktree(result: &RemoveResult) -> bool {
|
|
|
|
|
|
matches!(
|
|
|
|
|
|
result,
|
|
|
|
|
|
RemoveResult::RemovedWorktree {
|
|
|
|
|
|
changed_directory: true,
|
|
|
|
|
|
..
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The branch a `RemovedWorktree` removal will **keep** — worktree gone, branch
|
|
|
|
|
|
/// retained — or `None` if the removal will delete the branch (or there's no
|
|
|
|
|
|
/// branch). Drives the `alt-x` in-place morph: a kept branch turns the row into
|
|
|
|
|
|
/// a `/ branch` row rather than dropping it.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Mirrors [`delete_branch_if_safe`] exactly so the prediction can't drift from
|
|
|
|
|
|
/// the deletion the background `do_removal` performs: force always deletes; a
|
|
|
|
|
|
/// `Keep` flag always retains; otherwise the branch is kept precisely when it is
|
|
|
|
|
|
/// **not** integrated into the same `target_branch.unwrap_or("HEAD")` the actual
|
|
|
|
|
|
/// delete checks (`Repository::integration_reason` → `None`). A `capture_refs`
|
|
|
|
|
|
/// or integration error yields `None` (fall back to the drop path) — never a
|
|
|
|
|
|
/// morph the removal won't back up. Runs a couple of git commands on skim's
|
|
|
|
|
|
/// event loop, like `prepare_removal`'s own validation.
|
|
|
|
|
|
fn worktree_removal_keeps_branch(repo: &Repository, result: &RemoveResult) -> Option<String> {
|
|
|
|
|
|
let RemoveResult::RemovedWorktree {
|
|
|
|
|
|
branch_name: Some(branch),
|
|
|
|
|
|
deletion_mode,
|
|
|
|
|
|
target_branch,
|
|
|
|
|
|
..
|
|
|
|
|
|
} = result
|
|
|
|
|
|
else {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
};
|
|
|
|
|
|
if deletion_mode.is_force() {
|
|
|
|
|
|
return None; // `-D` deletes regardless of integration.
|
|
|
|
|
|
}
|
|
|
|
|
|
if deletion_mode.should_keep() {
|
|
|
|
|
|
return Some(branch.clone()); // `Keep` retains regardless of integration.
|
|
|
|
|
|
}
|
|
|
|
|
|
// SafeDelete: kept iff unmerged — the exact check `delete_branch_if_safe` runs.
|
|
|
|
|
|
let snapshot = repo.capture_refs().ok()?;
|
|
|
|
|
|
let target = target_branch.as_deref().unwrap_or("HEAD");
|
|
|
|
|
|
let (_, reason) = repo.integration_reason(&snapshot, branch, target).ok()?;
|
|
|
|
|
|
reason.is_none().then(|| branch.clone())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// Whether the row's underlying target still exists after `do_removal` ran — the
|
|
|
|
|
|
/// primary evidence for "was this actually removed," used in place of inferring
|
|
|
|
|
|
/// from `do_removal`'s `Result`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A `Result` is the wrong signal in two directions: a `RemovedWorktree` removal
|
|
|
|
|
|
/// can return `Err` *after* the worktree is already trashed (rendering or
|
|
|
|
|
|
/// spawning a `post-remove`/`post-switch` hook fails during the announcer flush),
|
|
|
|
|
|
/// and a `BranchOnly` safe-delete that raced from integrated to unmerged returns
|
|
|
|
|
|
/// `Ok` while leaving the branch in place. (The *predictable* unmerged case never
|
|
|
|
|
|
/// reaches here — [`removal_will_remove_target`] keeps that row without dropping
|
|
|
|
|
|
/// it.) Observing the target directly handles both: the worktree dir is gone once
|
|
|
|
|
|
/// removed (renamed into `.git/wt/trash/`), and the branch ref is gone once
|
|
|
|
|
|
/// deleted. The check runs on the background thread, off skim's event loop.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// `worktree_path.exists()` is the right signal here because the picker only ever
|
|
|
|
|
|
/// removes *non-current* worktrees — [`removal_targets_current_worktree`] keeps the
|
|
|
|
|
|
/// current one in place rather than removing it. So no empty placeholder directory
|
|
|
|
|
|
/// is ever left at `worktree_path` (that placeholder, which keeps `$PWD` valid, is
|
|
|
|
|
|
/// created only when removing the worktree the shell is sitting in — see
|
|
|
|
|
|
/// [`crate::output::handlers`]). A successful removal renames the whole tree away;
|
|
|
|
|
|
/// a failed one leaves it intact.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
fn removal_target_still_present(repo: &Repository, result: &RemoveResult) -> bool {
|
|
|
|
|
|
match result {
|
|
|
|
|
|
RemoveResult::RemovedWorktree { worktree_path, .. } => worktree_path.exists(),
|
|
|
|
|
|
RemoveResult::BranchOnly { branch_name, .. } => {
|
|
|
|
|
|
repo.branch(branch_name).exists_locally().unwrap_or(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// Stash the canonical "retained; unmerged" info + hint pair (deduped), drained
|
|
|
|
|
|
/// to stderr once the picker releases the terminal. Used by
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// [`AltXRemover::keep_unremovable_row`] — a branch-only row whose unmerged
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// branch `SafeDelete` declines to delete stays put, and this explains the
|
|
|
|
|
|
/// no-op. (A worktree removal that keeps its branch instead transforms the row
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// to `/ branch` live — see [`AltXRemover::morph_and_remove_in_background`] —
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// so it needs no stashed message.) The pair is the one `wt remove` itself
|
|
|
|
|
|
/// prints — see [`crate::output::retained_unmerged_branch_messages`].
|
|
|
|
|
|
fn stash_retained_unmerged_branch(stashed: &Mutex<Vec<String>>, branch_name: &str) {
|
|
|
|
|
|
let (info, hint) = crate::output::retained_unmerged_branch_messages(branch_name);
|
|
|
|
|
|
let mut stashed = stashed.lock().unwrap();
|
|
|
|
|
|
if !stashed.contains(&info) {
|
|
|
|
|
|
stashed.push(info);
|
|
|
|
|
|
stashed.push(hint);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Stash the "can't remove the current worktree here" info + hint pair (deduped),
|
|
|
|
|
|
/// drained to stderr once the picker releases the terminal. Used by
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// [`AltXRemover::keep_current_worktree_row`] — alt-x on the worktree the
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// picker was launched from keeps the row and explains, since removing it would
|
|
|
|
|
|
/// have to switch the shell elsewhere first.
|
|
|
|
|
|
fn stash_current_worktree_hint(stashed: &Mutex<Vec<String>>) {
|
|
|
|
|
|
let info = info_message("Can't remove the current worktree from the picker").to_string();
|
|
|
|
|
|
let hint = hint_message("Switch to another worktree first").to_string();
|
|
|
|
|
|
let mut stashed = stashed.lock().unwrap();
|
|
|
|
|
|
if !stashed.contains(&info) {
|
|
|
|
|
|
stashed.push(info);
|
|
|
|
|
|
stashed.push(hint);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 11:43:14 -07:00
|
|
|
|
/// The optimistically-dropped row [`restore_failed_removal`] puts back, plus the
|
|
|
|
|
|
/// display subject (`label` + `noun`, from [`removal_failure_subject`]) for its
|
|
|
|
|
|
/// `kept … could not remove it` message.
|
|
|
|
|
|
struct DroppedRow {
|
|
|
|
|
|
item: Arc<dyn SkimItem>,
|
|
|
|
|
|
/// The row's slot before it was dropped (clamped on re-insert if the list
|
|
|
|
|
|
/// shrank in the meantime).
|
|
|
|
|
|
pos: usize,
|
|
|
|
|
|
label: String,
|
|
|
|
|
|
noun: &'static str,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// Put a row back after its background removal didn't happen, closing the alt-x
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// loop so the list never shows a removal that didn't occur.
|
|
|
|
|
|
///
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// `invoke` drops a row optimistically once alt-x's validation passes, then
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// removes the target on a background thread. When the target unexpectedly
|
|
|
|
|
|
/// survives (data safety: a clean-check race against `ensure_clean`, a locked
|
|
|
|
|
|
/// directory, a failing `pre-remove` hook, or a `BranchOnly` delete that raced
|
|
|
|
|
|
/// from integrated to unmerged — see [`removal_target_still_present`]; the
|
|
|
|
|
|
/// predictably-kept unmerged branch is filtered earlier by
|
|
|
|
|
|
/// [`removal_will_remove_target`]), the row must reappear. This re-inserts it into
|
2026-07-01 11:43:14 -07:00
|
|
|
|
/// `shared_items` at its original slot, flashes the `kept` reason in the header and
|
|
|
|
|
|
/// stashes the same line (drained to stderr once skim releases the terminal; the
|
|
|
|
|
|
/// full error, if any, is in the `tracing::warn!` the caller emits), then queues a
|
|
|
|
|
|
/// [`resync_pool_action`] to re-show it.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Re-inserting at the removed row's old slot lands the cursor back on the row for
|
|
|
|
|
|
/// free: the drop slid the successor up into that slot under the cursor, so the
|
|
|
|
|
|
/// re-insert pushes the successor back down and the restored row takes the cursor's
|
|
|
|
|
|
/// position. Runs off the event loop (the background removal thread), so it can't
|
|
|
|
|
|
/// touch `App` directly — the queued action does the [`resync_pool`] on the loop.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
fn restore_failed_removal(
|
|
|
|
|
|
items: &Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
|
2026-07-01 11:43:14 -07:00
|
|
|
|
header_flash: &Arc<items::HeaderFlash>,
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
|
|
|
|
|
|
stashed_warnings: &Arc<Mutex<Vec<String>>>,
|
2026-07-01 11:43:14 -07:00
|
|
|
|
dropped: DroppedRow,
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
) {
|
2026-07-01 11:43:14 -07:00
|
|
|
|
let DroppedRow {
|
|
|
|
|
|
item,
|
|
|
|
|
|
pos,
|
|
|
|
|
|
label,
|
|
|
|
|
|
noun,
|
|
|
|
|
|
} = dropped;
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
{
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
let mut items = items.lock().unwrap();
|
2026-07-01 11:43:14 -07:00
|
|
|
|
let token = item.output().into_owned();
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// A concurrent restore (rapid alt-x on the same row) may have already
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
// put it back; don't duplicate it.
|
2026-07-01 11:43:14 -07:00
|
|
|
|
if items.iter().any(|it| it.output().as_ref() == token) {
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Another removal may have shrunk the list since the drop; clamp.
|
2026-07-01 11:43:14 -07:00
|
|
|
|
let insert_at = pos.min(items.len());
|
|
|
|
|
|
items.insert(insert_at, item);
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
}
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
2026-07-01 11:43:14 -07:00
|
|
|
|
// Surface the "couldn't remove" reason two ways, like the morph revert
|
|
|
|
|
|
// ([`revert_morph`]): flash it in the header now (the row is back under the
|
|
|
|
|
|
// cursor, so the *why* lands where the user is looking) and stash the same line
|
|
|
|
|
|
// to drain to stderr on exit. A genuine failure — the removal was attempted and
|
|
|
|
|
|
// the target survived — so warning (▲), not the keep paths' by-design info (○).
|
|
|
|
|
|
let warning = warning_message(cformat!(
|
|
|
|
|
|
"Kept <bold>{label}</> {noun} — could not remove it"
|
|
|
|
|
|
))
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
flash_header(header_flash, render_tx, warning.clone());
|
|
|
|
|
|
stashed_warnings.lock().unwrap().push(warning);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
let Some(event_tx) = render_tx.get() else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// Re-show the restored row by rebuilding skim's pool from the list it's back in.
|
|
|
|
|
|
let _ = event_tx.try_send(Event::Action(resync_pool_action(Arc::clone(items))));
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 12:18:42 -07:00
|
|
|
|
impl CommandCollector for PickerCollector {
|
|
|
|
|
|
fn invoke(
|
|
|
|
|
|
&mut self,
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
cmd: &str,
|
2026-03-23 12:18:42 -07:00
|
|
|
|
components_to_stop: Arc<AtomicUsize>,
|
|
|
|
|
|
) -> (SkimItemReceiver, Sender<i32>) {
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let _ = components_to_stop;
|
|
|
|
|
|
|
|
|
|
|
|
// alt-r refresh: `reload(refresh)` re-runs collect and streams a fresh
|
|
|
|
|
|
// list. The new pipeline's threads feed `rx`; on completion their senders
|
|
|
|
|
|
// drop and skim's reload sees EOF. The returned handler and join handles
|
|
|
|
|
|
// are kept alive by those threads, so let them drop here. On a spawn
|
|
|
|
|
|
// failure we fall through and re-stream the current items unchanged.
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
// If the failure hit after `PreviewOrchestrator::refresh` ran (a
|
|
|
|
|
|
// thread-spawn error — resource-exhaustion territory), those rows'
|
|
|
|
|
|
// tokens are already superseded against a cleared cache, so their
|
|
|
|
|
|
// previews sit on placeholders until the next successful refresh.
|
|
|
|
|
|
// Accepted: un-bumping the generation would instead break a
|
|
|
|
|
|
// partially-started spawn's live producers (the collect thread can
|
|
|
|
|
|
// already be running when the `--prs` thread spawn is what failed).
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
//
|
|
|
|
|
|
// `alt-x` removal does NOT route here — it runs synchronously through
|
|
|
|
|
|
// [`AltXRemover`] / [`resync_pool`] instead of a `reload`, so `refresh` is
|
|
|
|
|
|
// the only command this collector now sees. The re-stream below stays as the
|
|
|
|
|
|
// fall-through for a failed `refresh` spawn.
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
if cmd.trim() == "refresh" {
|
2026-06-25 13:05:00 -07:00
|
|
|
|
match self.factory.spawn(true) {
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
Ok(SpawnedPipeline { rx, .. }) => {
|
|
|
|
|
|
let (tx_interrupt, _rx_interrupt) = bounded(1);
|
|
|
|
|
|
return (rx, tx_interrupt);
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => log::warn!("picker: refresh failed: {e:#}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// Stream the current items through a channel for skim to consume. skim
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
// 4.x's item channel carries Vec batches, so send the whole list as a
|
|
|
|
|
|
// single batch; unbounded means the send never blocks.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
let items = self.items.lock().unwrap();
|
|
|
|
|
|
let (tx, rx) = unbounded();
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
let batch: Vec<Arc<dyn SkimItem>> = items.iter().map(Arc::clone).collect();
|
|
|
|
|
|
let _ = tx.send(batch);
|
2026-03-23 12:18:42 -07:00
|
|
|
|
drop(tx);
|
|
|
|
|
|
|
|
|
|
|
|
// Dummy interrupt channel — no subprocess to kill.
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// The reader's collect_item thread handles its own components_to_stop
|
|
|
|
|
|
// accounting; we just need a valid Sender to satisfy the trait signature.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
let (tx_interrupt, _rx_interrupt) = bounded(1);
|
|
|
|
|
|
(rx, tx_interrupt)
|
|
|
|
|
|
}
|
2026-03-05 21:52:29 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
/// Whether every `pre-remove` / `post-remove` / `post-switch` command this
|
|
|
|
|
|
/// removal would run is already approved — a read-only check, no prompt.
|
|
|
|
|
|
///
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
/// `repo` is the worktree the picker is operating from; its `.config/wt.toml`
|
|
|
|
|
|
/// is what every removal hook resolves against, matching `wt remove` /
|
|
|
|
|
|
/// `wt merge`. `main_path` is the post-removal destination (the `post-switch`
|
|
|
|
|
|
/// anchor); `worktree_path` is the worktree being removed (the `pre-remove` /
|
|
|
|
|
|
/// `post-remove` anchor). The picker can't prompt mid-render, so it runs the
|
|
|
|
|
|
/// removal's hooks only when they're already approved (e.g. from a prior
|
|
|
|
|
|
/// `wt remove` / `wt merge`) and skips them otherwise — unapproved project
|
|
|
|
|
|
/// commands must never run. See CLAUDE.md → "Project Commands Run Only After
|
|
|
|
|
|
/// Approval".
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
fn approved_removal_plan(
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
repo: &Repository,
|
fix(hooks): approve `post-switch` against the removal's destination worktree (#2748)
When a worktree is removed, the `post-switch` hook runs in the
*destination* worktree — where the user lands — which
`prepare_worktree_removal` records as `RemoveResult.main_path` (the
primary worktree, except cwd when the primary worktree is itself the
removal target). But `wt remove`'s and `wt step prune`'s approval gates
collected the `post-switch` commands to prompt for from
`repo.home_path()` instead. They agree in the common case (`main_path ==
home_path()`), so nobody noticed — but in a bare repo, removing the
default-branch worktree from a *different* worktree, `main_path ==
current_path` while the gate read `home_path()`, so the gate approved
the primary's `post-switch` while the executor ran cwd's — an unapproved
project command could run. (`wt merge` and the picker's
`removal_hooks_approved`, added in #2746, already passed the destination
correctly.)
## What changed
`collect_remove_hook_commands` now takes `(removed_worktree_paths:
&[&Path], destination_paths: &[&Path])` instead of `(primary_repo:
&Repository, removed_worktree_paths)` — it collects `pre-remove` /
`post-remove` from each removed worktree and `post-switch` from each
destination (path-deduped, since the common case is the same primary
repeated). New `RemoveResult::destination_path() -> Option<&Path>`
returns `main_path` for `RemovedWorktree`, `None` for `BranchOnly` — a
sibling of the existing `removed_worktree_path()`. Callers updated:
- `wt remove` (single):
`approve_remove(result.removed_worktree_path().as_slice(),
result.destination_path().as_slice(), …)`. (Multi: the same projections
over the plan list.)
- `wt merge`: passes `&[destination_path]` — matches
`finish_after_merge`'s `RemoveResult { main_path: destination_path, …
}`.
- `wt step prune`: passes `&[home_path()]` — a prune candidate is never
the primary worktree (`gather_check_items` filters non-linked worktrees
and the default-branch worktree), so each candidate's removal
destination is always `home_path()`.
- The picker's `removal_hooks_approved` gains a `main_path` param and
passes `&[main_path]`.
The `commands::hooks` "Which `.config/wt.toml` a hook reads" table row
for `post-switch after a removal` now points at
`RemoveResult::destination_path` and notes the bare-repo cwd case.
## Testing
Behavior-neutral in every case the test suite exercises — `post-switch`
only fires when `changed_directory` (the removed worktree was cwd), and
in all of those sub-cases `main_path` resolves to `home_path()` anyway,
so the old and new gates collect the same commands. The change closes
the latent decoupling. No new test added: the only observable difference
is the bare-repo-remove-the-primary-from-elsewhere corner, and a
dedicated integration test for it would be disproportionate; the new
code paths (`destination_path()` on both variants, the destination loop
in `collect_remove_hook_commands`, the `main.rs` plumbing) are covered
by the existing `wt remove` / `wt merge` / `wt step prune` integration
tests and the `test_do_removal_*` picker unit tests. `cargo run -- hook
pre-merge --yes` is green (3693 tests, lints, fmt, doctests).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:13:45 -07:00
|
|
|
|
main_path: &Path,
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
worktree_path: &Path,
|
|
|
|
|
|
approvals: &Approvals,
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
) -> anyhow::Result<ApprovedHookPlan> {
|
|
|
|
|
|
// Non-fatal: an unresolvable project identifier just means no project
|
|
|
|
|
|
// pipeline can be matched against approvals — `approve_readonly` then
|
|
|
|
|
|
// drops them (fail-closed), rather than aborting the picker removal.
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
let project_id = repo.project_identifier().ok();
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
let pid = project_id.as_deref();
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
let user = repo.user_config();
|
|
|
|
|
|
let project_config = repo.load_project_config()?;
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
|
refactor: collapse duplicated code paths onto single sources (#2932)
Round 2 of the simplification backlog: six independent dedups, each
collapsing a duplicated code path onto a single source. No behavior
change — render output is byte-identical and the full suite (3869
tests), clippy, fmt, and doctests pass. ~137 net lines removed.
| Item | Change |
|------|--------|
| C2 `git/error.rs` | Four `GitError` render arms rebuilt their title
string inline; now call `self.title()`, the documented single source the
other ~110 arms already use. |
| D2 `hook_plan.rs` + 5 gates | `HookPlanBuilder::new(project_config,
user, project_id)` carries the selection context once, so `add(anchor,
hook_types)` drops three repeated trailing args across all 11 call
sites. The structure now guarantees every `add` selects from the same
config context. |
| F2 `command_executor.rs` | Deleted `AnnouncePolicy`, a strict 1:1
projection of `PipelineKind`; `announce_command` matches `PipelineKind`
directly. |
| F3 `shell/paths.rs` | Memoized the `nu` config-dir query in a
`OnceLock` so `nu` spawns at most once per process instead of twice
during `config shell install`. |
| F4 `list/layout.rs` | Dropped the one-line
`calculate_layout_from_basics` wrapper; the `Some/None` width match
collapses to `list_width.unwrap_or_else(terminal_width)`. |
| find_remote `repository/remotes.rs` |
`find_remote_for_repo`/`_for_azure` shared a byte-identical parse+loop
differing only in the predicate; extracted `find_remote(impl
Fn(&GitRemoteUrl) -> bool)`. |
Deliberately scoped out (kept tight, documented for later): collapsing
F3's two `nu` resolvers (their fallback paths legitimately differ on
macOS-without-`nu`), an `approve_or_empty` helper (one clean caller
only), and the `valid_*_config_keys` generic (only home creates awkward
cross-module coupling).
No CLI flags or config-file format touched (protected interfaces).
Behavior-equivalence of each dedup verified by inspection and by a
code-review pass.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:08:45 -07:00
|
|
|
|
let mut builder = HookPlanBuilder::new(project_config.as_ref(), user, pid);
|
|
|
|
|
|
builder.add(worktree_path, &[HookType::PreRemove, HookType::PostRemove]);
|
|
|
|
|
|
builder.add(main_path, &[HookType::PostSwitch]);
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
Ok(builder.finish().approve_readonly(approvals, pid))
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// Everything needed to (re)spawn the picker's collect pipeline. Used once at
|
|
|
|
|
|
/// startup and again on every `alt-r` refresh — which re-runs `collect` so
|
|
|
|
|
|
/// worktrees and branches created outside the session (a teammate's push, a
|
|
|
|
|
|
/// parallel agent) appear without reopening the picker.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Each [`spawn`](Self::spawn) builds a *fresh* progressive handler (its
|
|
|
|
|
|
/// `OnceLock` slots can't be reset) and item channel, but shares the
|
feat(picker): refresh previews on alt-r, not just the row list (#3293)
## What
`alt-r` in the `wt switch` picker re-ran `collect` (refreshing the rows,
CI, and worktree inventory) but the **preview pane kept serving stale
content**. The in-memory preview cache is keyed by `(branch, mode)` with
no SHA, and the same cache was deliberately shared "warm" across reloads
— so a refresh never recomputed the working-tree / log / branch-diff /
upstream / summary tabs. Edit a tracked file, hit `alt-r`, and the diff
pane still showed the pre-edit state.
This clears the in-memory preview cache on the refresh spawn
(`PipelineFactory::spawn`, gated on `rebuild_repo` — true only on
`alt-r`), so each rebuilt row recomputes against its current
`item.head()`. The SHA- and diff-hash-keyed on-disk caches make an
unchanged branch a cheap re-read; only genuinely changed content pays a
recompute. `pr` / `comments` are cleared too, so a refresh also
re-fetches their forge data.
## Also: a unifying spec
There was no single write-up of the picker's preview-caching system —
the knowledge was scattered across four module docstrings. This adds a
module-level spec at the top of `preview_orchestrator.rs` (the hub that
owns the in-memory cache, the `fill` choke point, and the precompute
tiers): the two tiers, what backs each mode on a miss, the invalidation
rules, and exactly what `alt-r` does and doesn't refresh. Back-pointers
added from `preview_cache.rs` and `items.rs`.
## Known limitations (documented, not fixed here)
Both trace to one root — the orchestrator is built once and shares the
**startup** repo, with no spawn generation:
1. **Stale BranchDiff base** — if the *default* branch moves externally
mid-session, BranchDiff recomputes the row's fresh head against a stale
base SHA. Pre-existing and orthogonal to `alt-r`; not worsened here.
2. **Narrow stale-fill race** — a prior spawn's still-draining
precompute task can fill the just-cleared cache with stale content that
the new task then defers to. Opens only on a large repo / slow summaries
when content moved in the drain window; the common "I edited the branch
I'm viewing" case doesn't hit it (that branch's precompute finished at
picker open); self-heals on the next refresh.
The structural fix for both is to give the orchestrator the current
spawn's repo plus a generation counter (mirroring `prs_epoch`); left as
a follow-up since it's a refactor of a concurrency-critical module.
## Testing
- Unit test: cache cleared on a refresh spawn, kept warm on the initial
spawn.
- End-to-end PTY test: open the picker on a clean tree, edit a tracked
file, `alt-r`, assert the diff appears (and the stale "no uncommitted
changes" pane is gone).
Both were confirmed to fail with the one-line clear neutralized.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:26:27 -07:00
|
|
|
|
/// session-long state — the orchestrator / preview cache (warm across row
|
|
|
|
|
|
/// navigation and the fall-through re-stream; a refresh clears it so previews
|
|
|
|
|
|
/// recompute — see [`spawn`](Self::spawn)), `shared_items` and `shortcut_table`
|
|
|
|
|
|
/// (which `on_skeleton` seeds and the
|
fix(picker): keep --prs rows visible after an alt-x removal (#3275)
## The bug
In the `wt switch --prs` interactive picker, removing a worktree row
with `alt-x` made the streamed PR/MR rows vanish from the list until the
user pressed `alt-r` to refresh. The worktree/branch rows survived; only
the `--prs` rows disappeared.
## Root cause
The `alt-x` removal rework (`d66bc6af5`) replaced the old `reload(remove
{})` with a synchronous `resync_pool` that rebuilds skim's item pool
from the picker's `shared_items` Vec. But `shared_items` only ever held
the skeleton (worktree/branch) rows: `on_skeleton` populates it, while
the `--prs` thread streams its rows straight to skim's item channel
(`prs::fetch_and_stream` → `tx.send`) and never recorded them in
`shared_items`. So when `resync_pool` rebuilt the pool from
`shared_items`, the PR rows were dropped. They only reappeared when
`alt-r` re-ran the whole collect + `--prs` pipeline. (The old `reload`
path had the same blind spot; it matters more now that `alt-x` is the
sole removal path.)
## The fix
The `--prs` thread now appends its PR/MR rows into `shared_items` as
well as streaming them to skim — the same way it already extends
`shortcut_table`. With `shared_items` holding the full row set (header +
worktree/branch + PR/MR rows), `resync_pool` preserves the PR rows on an
`alt-x` removal for free.
The append is guarded by a per-spawn epoch counter
(`PipelineFactory::prs_epoch`, handed to each spawn's `--prs` thread via
`PrsShared`). An `alt-r` refresh spawns a fresh `--prs` thread while the
prior spawn's forge call may still be in flight; without the guard, that
stale call (whose skim channel is already dropped) would re-add
now-duplicate rows to the list a newer spawn rebuilt. The epoch is read
under the `shared_items` lock so the check pairs with the next spawn's
`on_skeleton` overwrite, which holds the same lock. The append is
ordered before `tx.send` so the rows reach `shared_items` no later than
they reach skim's pool (favoring a sub-microsecond transient-duplicate
window over re-dropping rows, were the order reversed).
The no-flash cursor behavior from `d66bc6af5` is unchanged: the rebuilt
list is just longer, so the cursor holds its index and the row that
slides into the removed slot lands under it.
## Testing
New PTY regression test
`test_switch_picker_prs_rows_survive_alt_x_removal` drives the drop path
(a clean, integrated worktree) in `--prs` mode and asserts the `#42` PR
row survives the removal. The mock answers `gh pr list --state` (the
`--prs` fetch) with PR #42 but `gh pr list --head <branch>` (the
per-worktree CI fetch) with an empty list, so `#42` appears only as a
`--prs` row, never folded into a worktree row's CI cell. Confirmed: the
test fails without the fix and passes with it. Full pre-merge gate green
(4257 tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:41:48 -07:00
|
|
|
|
/// `--prs` thread extends), and skim's `render_tx`. Held by [`PickerCollector`]
|
|
|
|
|
|
/// so a refresh can re-enter the pipeline.
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
struct PipelineFactory {
|
|
|
|
|
|
repo: Repository,
|
|
|
|
|
|
render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
|
|
|
|
|
|
shared_items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
|
|
|
|
|
|
shortcut_table: ShortcutTable,
|
|
|
|
|
|
preview_cache: PreviewCache,
|
|
|
|
|
|
orchestrator: Arc<PreviewOrchestrator>,
|
|
|
|
|
|
stashed_warnings: Arc<Mutex<Vec<String>>>,
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// Handoff of the collect layout to the collector, for rendering a
|
|
|
|
|
|
/// `/ branch` row on the same grid at `alt-x` time. Filled by the handler's
|
|
|
|
|
|
/// `provide_layout`, read by [`PickerCollector`]. See [`items::LayoutSlot`].
|
|
|
|
|
|
layout_slot: items::LayoutSlot,
|
2026-06-30 20:56:08 -07:00
|
|
|
|
/// Shared picker-lifetime with the header item and [`AltXRemover`]: a declined
|
|
|
|
|
|
/// `alt-x` flashes a transient "couldn't remove this row" line in the header
|
|
|
|
|
|
/// (see [`items::HeaderFlash`]). One slot for the picker's life, re-shared into
|
|
|
|
|
|
/// each spawn's header item, so a reload reads the same (cleared) flash.
|
|
|
|
|
|
header_flash: Arc<items::HeaderFlash>,
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
preview_dims: (usize, usize),
|
|
|
|
|
|
skim_list_width: usize,
|
|
|
|
|
|
command_timeout: Option<std::time::Duration>,
|
|
|
|
|
|
llm_command: Option<String>,
|
|
|
|
|
|
summary_hint: Option<String>,
|
|
|
|
|
|
show_branches: bool,
|
|
|
|
|
|
show_remotes: bool,
|
|
|
|
|
|
show_prs: bool,
|
|
|
|
|
|
is_preview_bench: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The product of one [`PipelineFactory::spawn`]: skim's item receiver plus the
|
|
|
|
|
|
/// handler and thread handles the caller manages (joined in the dry-run path,
|
|
|
|
|
|
/// dropped — detaching the threads — in the interactive and refresh paths).
|
|
|
|
|
|
struct SpawnedPipeline {
|
|
|
|
|
|
rx: SkimItemReceiver,
|
|
|
|
|
|
handler: Arc<progressive_handler::PickerHandler>,
|
|
|
|
|
|
collect_handle: std::thread::JoinHandle<()>,
|
|
|
|
|
|
prs_handle: Option<std::thread::JoinHandle<()>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PipelineFactory {
|
|
|
|
|
|
/// Build a fresh handler + item channel, start the `picker-collect` thread
|
|
|
|
|
|
/// (and the `picker-prs` thread when `--prs` is active), and hand back the
|
fix(picker): stop burning a core while background collect is pending (#3534)
`wt switch` burned ~100% of one core whenever the picker looked idle but
background collect work was still pending: a slow CI fetch, or an LLM
branch summary (`[list] summary = true` with a `commit.generation`
command). skim's reader polls its item channel with kanal's
`recv_timeout(1ms)`, and kanal's timeout wait yields in a loop instead
of parking, so an open-but-empty channel spins a full core for as long
as any `SkimItemSender` is alive. The handler held its sender for the
whole collect, a contract inherited from the skim 0.20 tuikit backend,
whose 100ms repaint heartbeat needed a live reader. In skim 5.x,
in-place row updates surface via injected `Event::Render` and never
touch the channel, so nothing needs the sender past the skeleton batch.
The fix consumes the handler's sender at its single send. The channel
now closes once the last batch is in (the skeleton, or the `--prs`
rows), and skim's reader exits while collect keeps grinding through
in-place updates.
Measured on the wt-perf picker-test repo, 10s window on an idle picker
with a `sleep 30` generation command pending: debug 98% → 1.5% of a
core, release 99% → 0.6%. A truly idle picker was already fine (~1%);
the burn only ever ran while a sender stayed alive. `/usr/bin/sample`
pinned the spin to `skim::reader::collect_items` →
`kanal::signal::Signal::wait_timeout` (4027 of 4095 frames).
Two bounded windows remain: picker startup until the skeleton batch
lands, and `--prs` until the forge call returns. Eliminating those needs
an upstream skim fix (the collect loop parking instead of polling); the
busy-poll is unchanged through skim 5.4.0.
Testing: a new unit test asserts the channel closes at the skeleton
send; the picker unit tests and the PTY `switch_picker` suite (which
exercise fast-skeleton EOF, preview auto-refresh, `--prs` streaming, and
alt-r reload) pass.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:02:47 -07:00
|
|
|
|
/// receiver skim reads. Every sender drops as soon as its batch is sent —
|
|
|
|
|
|
/// the handler's at the skeleton send, the `--prs` thread's when its rows
|
|
|
|
|
|
/// land — so skim's reader sees EOF once the last batch is in, which is
|
|
|
|
|
|
/// what ends a refresh's `reload`. Collect keeps grinding past that point
|
|
|
|
|
|
/// (CI fetches, summaries) through in-place row updates that never touch
|
|
|
|
|
|
/// the channel.
|
2026-06-25 13:05:00 -07:00
|
|
|
|
/// `rebuild_repo` controls the worktree/branch inventory source. A refresh
|
|
|
|
|
|
/// (`alt-r`) passes `true` to rebuild a fresh `Repository`, re-enumerating
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
/// after an in-picker removal, and to run `PreviewOrchestrator::refresh` —
|
|
|
|
|
|
/// which supersedes the prior spawn's preview producers, rebinds preview
|
|
|
|
|
|
/// compute to the fresh repo, and clears the in-memory preview cache so
|
feat(picker): refresh previews on alt-r, not just the row list (#3293)
## What
`alt-r` in the `wt switch` picker re-ran `collect` (refreshing the rows,
CI, and worktree inventory) but the **preview pane kept serving stale
content**. The in-memory preview cache is keyed by `(branch, mode)` with
no SHA, and the same cache was deliberately shared "warm" across reloads
— so a refresh never recomputed the working-tree / log / branch-diff /
upstream / summary tabs. Edit a tracked file, hit `alt-r`, and the diff
pane still showed the pre-edit state.
This clears the in-memory preview cache on the refresh spawn
(`PipelineFactory::spawn`, gated on `rebuild_repo` — true only on
`alt-r`), so each rebuilt row recomputes against its current
`item.head()`. The SHA- and diff-hash-keyed on-disk caches make an
unchanged branch a cheap re-read; only genuinely changed content pays a
recompute. `pr` / `comments` are cleared too, so a refresh also
re-fetches their forge data.
## Also: a unifying spec
There was no single write-up of the picker's preview-caching system —
the knowledge was scattered across four module docstrings. This adds a
module-level spec at the top of `preview_orchestrator.rs` (the hub that
owns the in-memory cache, the `fill` choke point, and the precompute
tiers): the two tiers, what backs each mode on a miss, the invalidation
rules, and exactly what `alt-r` does and doesn't refresh. Back-pointers
added from `preview_cache.rs` and `items.rs`.
## Known limitations (documented, not fixed here)
Both trace to one root — the orchestrator is built once and shares the
**startup** repo, with no spawn generation:
1. **Stale BranchDiff base** — if the *default* branch moves externally
mid-session, BranchDiff recomputes the row's fresh head against a stale
base SHA. Pre-existing and orthogonal to `alt-r`; not worsened here.
2. **Narrow stale-fill race** — a prior spawn's still-draining
precompute task can fill the just-cleared cache with stale content that
the new task then defers to. Opens only on a large repo / slow summaries
when content moved in the drain window; the common "I edited the branch
I'm viewing" case doesn't hit it (that branch's precompute finished at
picker open); self-heals on the next refresh.
The structural fix for both is to give the orchestrator the current
spawn's repo plus a generation counter (mirroring `prs_epoch`); left as
a follow-up since it's a refactor of a concurrency-critical module.
## Testing
- Unit test: cache cleared on a refresh spawn, kept warm on the initial
spawn.
- End-to-end PTY test: open the picker on a clean tree, edit a tracked
file, `alt-r`, assert the diff appears (and the stale "no uncommitted
changes" pane is gone).
Both were confirmed to fail with the one-line clear neutralized.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:26:27 -07:00
|
|
|
|
/// previews recompute (see the `spawn_repo` binding, and the
|
|
|
|
|
|
/// `preview_orchestrator` spec for what a refresh does and doesn't refresh).
|
|
|
|
|
|
/// The initial spawn passes `false` to reuse the startup repo, whose cache
|
|
|
|
|
|
/// the prelude already primed — nothing has mutated yet, so reusing it is
|
|
|
|
|
|
/// correct and avoids re-paying `git worktree list` / `local_branches` on the
|
|
|
|
|
|
/// first-paint hot path (doubling them there slows the picker, worst on
|
|
|
|
|
|
/// Windows).
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// The rebuild is also what lets `alt-r` drop a worktree an in-picker `alt-x`
|
|
|
|
|
|
/// removed: re-enumerating from a fresh handle skips the gone worktree, where
|
|
|
|
|
|
/// the startup cache would still list it.
|
2026-06-25 13:05:00 -07:00
|
|
|
|
fn spawn(&self, rebuild_repo: bool) -> anyhow::Result<SpawnedPipeline> {
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded();
|
|
|
|
|
|
|
|
|
|
|
|
// Fresh per spawn: the header shows a "loading…" marker keyed to this
|
|
|
|
|
|
// flag while the forge call is in flight.
|
|
|
|
|
|
let prs_loading: Option<Arc<AtomicBool>> =
|
|
|
|
|
|
(self.show_prs && !self.is_preview_bench).then(|| Arc::new(AtomicBool::new(true)));
|
|
|
|
|
|
|
2026-06-25 13:05:00 -07:00
|
|
|
|
// Worktree/branch inventory source for this spawn. The factory's `repo`
|
|
|
|
|
|
// was primed with `git worktree list` / `local_branches` at picker
|
|
|
|
|
|
// startup, and those `RepoCache` cells are `OnceCell`s that are never
|
|
|
|
|
|
// invalidated. A refresh re-probing that shared cache would re-serve the
|
|
|
|
|
|
// startup list, so after an in-picker removal the removed worktrees would
|
|
|
|
|
|
// still appear and collect's per-worktree git ops would fail against the
|
|
|
|
|
|
// gone branches ("fatal: Needed a single revision"). So a refresh
|
|
|
|
|
|
// (`rebuild_repo`) builds a fresh `Repository::at` — the post-mutation
|
|
|
|
|
|
// discipline the `RepoCache` docs and `prepare_removal` already require.
|
|
|
|
|
|
// The initial spawn skips the rebuild: the primed cache is still valid,
|
|
|
|
|
|
// and rebuilding would re-pay both git calls on the first-paint path.
|
|
|
|
|
|
// The collect thread (`bg_repo`), the `--prs` thread (`prs_repo`), and
|
|
|
|
|
|
// the skeleton handler's inventory reads all share this one snapshot.
|
|
|
|
|
|
let spawn_repo = if rebuild_repo {
|
feat(picker): refresh previews on alt-r, not just the row list (#3293)
## What
`alt-r` in the `wt switch` picker re-ran `collect` (refreshing the rows,
CI, and worktree inventory) but the **preview pane kept serving stale
content**. The in-memory preview cache is keyed by `(branch, mode)` with
no SHA, and the same cache was deliberately shared "warm" across reloads
— so a refresh never recomputed the working-tree / log / branch-diff /
upstream / summary tabs. Edit a tracked file, hit `alt-r`, and the diff
pane still showed the pre-edit state.
This clears the in-memory preview cache on the refresh spawn
(`PipelineFactory::spawn`, gated on `rebuild_repo` — true only on
`alt-r`), so each rebuilt row recomputes against its current
`item.head()`. The SHA- and diff-hash-keyed on-disk caches make an
unchanged branch a cheap re-read; only genuinely changed content pays a
recompute. `pr` / `comments` are cleared too, so a refresh also
re-fetches their forge data.
## Also: a unifying spec
There was no single write-up of the picker's preview-caching system —
the knowledge was scattered across four module docstrings. This adds a
module-level spec at the top of `preview_orchestrator.rs` (the hub that
owns the in-memory cache, the `fill` choke point, and the precompute
tiers): the two tiers, what backs each mode on a miss, the invalidation
rules, and exactly what `alt-r` does and doesn't refresh. Back-pointers
added from `preview_cache.rs` and `items.rs`.
## Known limitations (documented, not fixed here)
Both trace to one root — the orchestrator is built once and shares the
**startup** repo, with no spawn generation:
1. **Stale BranchDiff base** — if the *default* branch moves externally
mid-session, BranchDiff recomputes the row's fresh head against a stale
base SHA. Pre-existing and orthogonal to `alt-r`; not worsened here.
2. **Narrow stale-fill race** — a prior spawn's still-draining
precompute task can fill the just-cleared cache with stale content that
the new task then defers to. Opens only on a large repo / slow summaries
when content moved in the drain window; the common "I edited the branch
I'm viewing" case doesn't hit it (that branch's precompute finished at
picker open); self-heals on the next refresh.
The structural fix for both is to give the orchestrator the current
spawn's repo plus a generation counter (mirroring `prs_epoch`); left as
a follow-up since it's a refactor of a concurrency-critical module.
## Testing
- Unit test: cache cleared on a refresh spawn, kept warm on the initial
spawn.
- End-to-end PTY test: open the picker on a clean tree, edit a tracked
file, `alt-r`, assert the diff appears (and the stale "no uncommitted
changes" pane is gone).
Both were confirmed to fail with the one-line clear neutralized.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:26:27 -07:00
|
|
|
|
// A refresh recomputes previews too, not just the row inventory.
|
|
|
|
|
|
// The in-memory preview cache is keyed by `(branch, mode)` with no
|
|
|
|
|
|
// SHA — the working-tree diff has no stable hash to key on — so a
|
|
|
|
|
|
// warm entry outlives the branch's commits or working tree moving
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
// and would re-serve a stale diff / log / summary. `refresh`
|
|
|
|
|
|
// supersedes the prior spawn's still-in-flight producers, rebinds
|
|
|
|
|
|
// preview compute to this fresh repo, and clears the cache, so
|
|
|
|
|
|
// each rebuilt row recomputes against its current `item.head()`
|
|
|
|
|
|
// from the rebuilt inventory; the on-disk caches (SHA-keyed for
|
|
|
|
|
|
// log / branch-diff / upstream, diff-hash-keyed for the summary)
|
|
|
|
|
|
// make an unchanged branch a cheap re-read, so only genuinely
|
|
|
|
|
|
// changed content pays a recompute. The `pr` / `comments` tabs
|
|
|
|
|
|
// already self-invalidate on the CI path; clearing them here too
|
|
|
|
|
|
// just means a refresh also re-fetches their forge data. See the
|
|
|
|
|
|
// `preview_orchestrator` module spec ("Spawn generations").
|
|
|
|
|
|
let repo = Repository::at(self.repo.discovery_path())?;
|
|
|
|
|
|
self.orchestrator.refresh(repo.clone());
|
|
|
|
|
|
repo
|
2026-06-25 13:05:00 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
self.repo.clone()
|
|
|
|
|
|
};
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
// This spawn's identity token, carried by everything the spawn
|
|
|
|
|
|
// starts and superseded by the next refresh (see `SpawnGeneration`).
|
|
|
|
|
|
let spawn_gen = self.orchestrator.generation();
|
2026-06-25 13:05:00 -07:00
|
|
|
|
|
2026-06-25 17:55:54 -07:00
|
|
|
|
// The skeleton→`--prs` handoff (column geometry + the branches already
|
|
|
|
|
|
// shown for dedup). Fresh per spawn so an alt-r reload's `--prs` thread
|
|
|
|
|
|
// reads *this* reload's branch set — a session-shared first-write-wins
|
|
|
|
|
|
// slot would feed it the original skeleton's stale set, double-listing or
|
|
|
|
|
|
// dropping a PR whose worktree was created/removed since (see
|
|
|
|
|
|
// `prs::Skeleton`). The grid is width-stable, so per-spawn grids are
|
|
|
|
|
|
// identical anyway.
|
|
|
|
|
|
let grid_slot = Arc::new(prs::GridSlot::new());
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let handler: Arc<progressive_handler::PickerHandler> =
|
|
|
|
|
|
Arc::new(progressive_handler::PickerHandler {
|
fix(picker): stop burning a core while background collect is pending (#3534)
`wt switch` burned ~100% of one core whenever the picker looked idle but
background collect work was still pending: a slow CI fetch, or an LLM
branch summary (`[list] summary = true` with a `commit.generation`
command). skim's reader polls its item channel with kanal's
`recv_timeout(1ms)`, and kanal's timeout wait yields in a loop instead
of parking, so an open-but-empty channel spins a full core for as long
as any `SkimItemSender` is alive. The handler held its sender for the
whole collect, a contract inherited from the skim 0.20 tuikit backend,
whose 100ms repaint heartbeat needed a live reader. In skim 5.x,
in-place row updates surface via injected `Event::Render` and never
touch the channel, so nothing needs the sender past the skeleton batch.
The fix consumes the handler's sender at its single send. The channel
now closes once the last batch is in (the skeleton, or the `--prs`
rows), and skim's reader exits while collect keeps grinding through
in-place updates.
Measured on the wt-perf picker-test repo, 10s window on an idle picker
with a `sleep 30` generation command pending: debug 98% → 1.5% of a
core, release 99% → 0.6%. A truly idle picker was already fine (~1%);
the burn only ever ran while a sender stayed alive. `/usr/bin/sample`
pinned the spin to `skim::reader::collect_items` →
`kanal::signal::Signal::wait_timeout` (4027 of 4095 frames).
Two bounded windows remain: picker startup until the skeleton batch
lands, and `--prs` until the forge call returns. Eliminating those needs
an upstream skim fix (the collect loop parking instead of polling); the
busy-poll is unchanged through skim 5.4.0.
Testing: a new unit test asserts the channel closes at the skeleton
send; the picker unit tests and the PTY `switch_picker` suite (which
exercise fast-skeleton EOF, preview auto-refresh, `--prs` streaming, and
alt-r reload) pass.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:02:47 -07:00
|
|
|
|
tx: Mutex::new(Some(tx.clone())),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
render_tx: Arc::clone(&self.render_tx),
|
|
|
|
|
|
last_render_poke: Mutex::new(Instant::now()),
|
|
|
|
|
|
shared_items: Arc::clone(&self.shared_items),
|
|
|
|
|
|
shortcut_table: Arc::clone(&self.shortcut_table),
|
|
|
|
|
|
rendered_slots: OnceLock::new(),
|
|
|
|
|
|
pr_status_slots: OnceLock::new(),
|
|
|
|
|
|
comments_fetched: OnceLock::new(),
|
|
|
|
|
|
local_content_slots: OnceLock::new(),
|
|
|
|
|
|
preview_cache: Arc::clone(&self.preview_cache),
|
2026-06-25 13:05:00 -07:00
|
|
|
|
repo: spawn_repo.clone(),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
orchestrator: Arc::clone(&self.orchestrator),
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
spawn_gen: spawn_gen.clone(),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
preview_dims: self.preview_dims,
|
|
|
|
|
|
llm_command: self.llm_command.clone(),
|
|
|
|
|
|
summary_hint: self.summary_hint.clone(),
|
|
|
|
|
|
stashed_warnings: Arc::clone(&self.stashed_warnings),
|
|
|
|
|
|
deferred_items: OnceLock::new(),
|
2026-06-25 17:55:54 -07:00
|
|
|
|
grid_slot: Arc::clone(&grid_slot),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
layout_slot: Arc::clone(&self.layout_slot),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
prs_loading: prs_loading.clone(),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
header_flash: Arc::clone(&self.header_flash),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let bg_handler: Arc<dyn collect::PickerProgressHandler> = handler.clone();
|
2026-06-25 13:05:00 -07:00
|
|
|
|
let bg_repo = spawn_repo.clone();
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let show_branches = self.show_branches;
|
|
|
|
|
|
let show_remotes = self.show_remotes;
|
|
|
|
|
|
let command_timeout = self.command_timeout;
|
|
|
|
|
|
let skim_list_width = self.skim_list_width;
|
|
|
|
|
|
let collect_handle = std::thread::Builder::new()
|
|
|
|
|
|
.name("picker-collect".into())
|
|
|
|
|
|
.spawn(move || {
|
|
|
|
|
|
let _ = collect::collect(
|
|
|
|
|
|
&bg_repo,
|
|
|
|
|
|
collect::ShowConfig::Resolved {
|
|
|
|
|
|
show_branches,
|
|
|
|
|
|
show_remotes,
|
|
|
|
|
|
command_timeout,
|
|
|
|
|
|
collect_deadline: None,
|
|
|
|
|
|
list_width: Some(skim_list_width),
|
|
|
|
|
|
progressive_handler: Some(bg_handler),
|
|
|
|
|
|
},
|
|
|
|
|
|
// Picker renders its own UI through `progressive_handler`;
|
|
|
|
|
|
// collect must not write to stdout.
|
|
|
|
|
|
RenderTarget::Json,
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.context("Failed to spawn picker-collect thread")?;
|
|
|
|
|
|
|
|
|
|
|
|
// PR/MR streaming (`--prs`). One forge call on its own thread holding
|
|
|
|
|
|
// another `tx` clone, so the frame paints from local data immediately and
|
|
|
|
|
|
// PR rows stream in (~1s) when the call returns.
|
|
|
|
|
|
let prs_handle = if let Some(prs_loading) = prs_loading {
|
|
|
|
|
|
let prs_tx = tx.clone();
|
2026-06-25 13:05:00 -07:00
|
|
|
|
let prs_repo = spawn_repo.clone();
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let prs_warnings = Arc::clone(&self.stashed_warnings);
|
|
|
|
|
|
let prs_orchestrator = Arc::clone(&self.orchestrator);
|
|
|
|
|
|
let prs_render_tx = Arc::clone(&self.render_tx);
|
|
|
|
|
|
let prs_shared = prs::PrsShared {
|
2026-06-25 17:55:54 -07:00
|
|
|
|
grid_slot: Arc::clone(&grid_slot),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
shortcut_table: Arc::clone(&self.shortcut_table),
|
fix(picker): keep --prs rows visible after an alt-x removal (#3275)
## The bug
In the `wt switch --prs` interactive picker, removing a worktree row
with `alt-x` made the streamed PR/MR rows vanish from the list until the
user pressed `alt-r` to refresh. The worktree/branch rows survived; only
the `--prs` rows disappeared.
## Root cause
The `alt-x` removal rework (`d66bc6af5`) replaced the old `reload(remove
{})` with a synchronous `resync_pool` that rebuilds skim's item pool
from the picker's `shared_items` Vec. But `shared_items` only ever held
the skeleton (worktree/branch) rows: `on_skeleton` populates it, while
the `--prs` thread streams its rows straight to skim's item channel
(`prs::fetch_and_stream` → `tx.send`) and never recorded them in
`shared_items`. So when `resync_pool` rebuilt the pool from
`shared_items`, the PR rows were dropped. They only reappeared when
`alt-r` re-ran the whole collect + `--prs` pipeline. (The old `reload`
path had the same blind spot; it matters more now that `alt-x` is the
sole removal path.)
## The fix
The `--prs` thread now appends its PR/MR rows into `shared_items` as
well as streaming them to skim — the same way it already extends
`shortcut_table`. With `shared_items` holding the full row set (header +
worktree/branch + PR/MR rows), `resync_pool` preserves the PR rows on an
`alt-x` removal for free.
The append is guarded by a per-spawn epoch counter
(`PipelineFactory::prs_epoch`, handed to each spawn's `--prs` thread via
`PrsShared`). An `alt-r` refresh spawns a fresh `--prs` thread while the
prior spawn's forge call may still be in flight; without the guard, that
stale call (whose skim channel is already dropped) would re-add
now-duplicate rows to the list a newer spawn rebuilt. The epoch is read
under the `shared_items` lock so the check pairs with the next spawn's
`on_skeleton` overwrite, which holds the same lock. The append is
ordered before `tx.send` so the rows reach `shared_items` no later than
they reach skim's pool (favoring a sub-microsecond transient-duplicate
window over re-dropping rows, were the order reversed).
The no-flash cursor behavior from `d66bc6af5` is unchanged: the rebuilt
list is just longer, so the cursor holds its index and the row that
slides into the removed slot lands under it.
## Testing
New PTY regression test
`test_switch_picker_prs_rows_survive_alt_x_removal` drives the drop path
(a clean, integrated worktree) in `--prs` mode and asserts the `#42` PR
row survives the removal. The mock answers `gh pr list --state` (the
`--prs` fetch) with PR #42 but `gh pr list --head <branch>` (the
per-worktree CI fetch) with an empty list, so `#42` appears only as a
`--prs` row, never folded into a worktree row's CI cell. Confirmed: the
test fails without the fix and passes with it. Full pre-merge gate green
(4257 tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:41:48 -07:00
|
|
|
|
shared_items: Arc::clone(&self.shared_items),
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
// This spawn's token: the thread appends its rows (and fans
|
|
|
|
|
|
// out per-row fetches) only while it is still current, so an
|
|
|
|
|
|
// earlier spawn's still-in-flight forge call can't add rows
|
|
|
|
|
|
// to this (or a later) spawn's list. See `prs::PrsShared`.
|
|
|
|
|
|
spawn_gen: spawn_gen.clone(),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
};
|
|
|
|
|
|
let prs_layout = prs::PrsLayout {
|
|
|
|
|
|
list_width: self.skim_list_width,
|
|
|
|
|
|
preview_dims: self.preview_dims,
|
|
|
|
|
|
};
|
|
|
|
|
|
Some(
|
|
|
|
|
|
std::thread::Builder::new()
|
|
|
|
|
|
.name("picker-prs".into())
|
|
|
|
|
|
.spawn(move || {
|
|
|
|
|
|
prs::stream_open_prs(
|
|
|
|
|
|
&prs_repo,
|
|
|
|
|
|
&prs_layout,
|
|
|
|
|
|
&prs_tx,
|
|
|
|
|
|
&prs_warnings,
|
|
|
|
|
|
&prs_orchestrator,
|
|
|
|
|
|
&prs_shared,
|
|
|
|
|
|
&prs::PrsStreamSignal {
|
|
|
|
|
|
pending: &prs_loading,
|
|
|
|
|
|
render_tx: &prs_render_tx,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.context("Failed to spawn picker-prs thread")?,
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
fix(picker): stop burning a core while background collect is pending (#3534)
`wt switch` burned ~100% of one core whenever the picker looked idle but
background collect work was still pending: a slow CI fetch, or an LLM
branch summary (`[list] summary = true` with a `commit.generation`
command). skim's reader polls its item channel with kanal's
`recv_timeout(1ms)`, and kanal's timeout wait yields in a loop instead
of parking, so an open-but-empty channel spins a full core for as long
as any `SkimItemSender` is alive. The handler held its sender for the
whole collect, a contract inherited from the skim 0.20 tuikit backend,
whose 100ms repaint heartbeat needed a live reader. In skim 5.x,
in-place row updates surface via injected `Event::Render` and never
touch the channel, so nothing needs the sender past the skeleton batch.
The fix consumes the handler's sender at its single send. The channel
now closes once the last batch is in (the skeleton, or the `--prs`
rows), and skim's reader exits while collect keeps grinding through
in-place updates.
Measured on the wt-perf picker-test repo, 10s window on an idle picker
with a `sleep 30` generation command pending: debug 98% → 1.5% of a
core, release 99% → 0.6%. A truly idle picker was already fine (~1%);
the burn only ever ran while a sender stayed alive. `/usr/bin/sample`
pinned the spin to `skim::reader::collect_items` →
`kanal::signal::Signal::wait_timeout` (4027 of 4095 frames).
Two bounded windows remain: picker startup until the skeleton batch
lands, and `--prs` until the forge call returns. Eliminating those needs
an upstream skim fix (the collect loop parking instead of polling); the
busy-poll is unchanged through skim 5.4.0.
Testing: a new unit test asserts the channel closes at the skeleton
send; the picker unit tests and the PTY `switch_picker` suite (which
exercise fast-skeleton EOF, preview auto-refresh, `--prs` streaming, and
alt-r reload) pass.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:02:47 -07:00
|
|
|
|
// Drop the local `tx` so the handler's clone (consumed at the skeleton
|
|
|
|
|
|
// send) and the `--prs` thread's clone are the only senders left —
|
|
|
|
|
|
// their release is what signals EOF to skim.
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
drop(tx);
|
|
|
|
|
|
|
|
|
|
|
|
Ok(SpawnedPipeline {
|
|
|
|
|
|
rx,
|
|
|
|
|
|
handler,
|
|
|
|
|
|
collect_handle,
|
|
|
|
|
|
prs_handle,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 00:07:30 -07:00
|
|
|
|
pub fn handle_picker(
|
2026-03-07 14:07:57 -08:00
|
|
|
|
cli_branches: bool,
|
|
|
|
|
|
cli_remotes: bool,
|
2026-06-22 16:34:04 -07:00
|
|
|
|
cli_prs: bool,
|
2026-03-13 22:42:40 +08:00
|
|
|
|
change_dir_flag: Option<bool>,
|
2026-05-20 21:05:19 -07:00
|
|
|
|
format: SwitchFormat,
|
2026-07-10 04:30:14 -07:00
|
|
|
|
execute: Option<&str>,
|
|
|
|
|
|
execute_args: &[String],
|
2026-03-07 14:07:57 -08:00
|
|
|
|
) -> anyhow::Result<()> {
|
bench: measure `wt switch` picker preview pre-compute workload (#2721)
## Summary
- Adds `picker_preview` benchmark group measuring "process spawn → all
preview tasks drained" for `wt switch`'s interactive picker.
- Introduces `WORKTRUNK_PREVIEW_BENCH=1`, an early-exit gate inside
`handle_picker` that runs the full prelude (collect, speculative spawn,
skeleton, initial + deferred precompute, `orchestrator.wait_for_idle()`)
and returns before skim launches or any JSON / stderr I/O. Shares the
dry-run path; behavior with the env var unset is unchanged.
- Closes the coverage gap behind #2662 / #2683 / #2685 / #2704, which
were tuned against `wt list` as a proxy because no direct picker
measurement existed.
## Why this measurement
Picker submits one preview-compute task per row to the global rayon
pool. The user-visible quantity to optimize is the responsiveness window
between picker launch and "all previews ready" (j/k navigation hits
cached content). Option 1 from the task — headless wall clock to drain —
is the cleanest measurable proxy and avoids the PTY route, which hits
the documented nextest/SIGTTOU pain on `shell-integration-tests`.
PTY-driven first-interactive-ready can be a follow-up.
## Variants
- `picker_preview/warm/typical-8`
- `picker_preview/cold/typical-8`
Cold uses `BatchSize::PerIteration` (not `SmallInput`): `SmallInput`
calls `setup` for an entire batch up front and then runs timed routines
back-to-back, so only the first iter in each batch is genuinely cold —
the rest hit a freshly populated `.git/wt/cache/`. `PerIteration`
invalidates immediately before every measured iteration; setup is far
cheaper than `wt switch`, so per-iter `Instant::now` doesn't dominate.
`sample_size(10)` + `measurement_time(35s)` per #2685's lead — slow
benches don't benefit from the default 30 samples.
`cfg(unix)`-gated with a no-op `main` on Windows; the picker is
Unix-only and `wt switch` (no args) hits the unavailable path before the
env var is consulted.
## Sample run
```
picker_preview/warm/typical-8 time: [185.62 ms 191.72 ms 200.77 ms]
picker_preview/cold/typical-8 time: [209.34 ms 226.23 ms 239.29 ms]
```
## Test plan
- [x] `cargo bench --bench picker_preview` runs cleanly on both variants
- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass
- [x] New `test_picker_preview_bench_produces_no_output` asserts
`WORKTRUNK_PREVIEW_BENCH=1` keeps stdout/stderr empty (covers the
env-gated branch, locks the no-I/O contract)
- [x] Smoke test: `wt switch` with `WORKTRUNK_PREVIEW_BENCH` unset still
hits the TTY error path (user-visible behavior unchanged)
- [x] Smoke test: `WORKTRUNK_PICKER_DRY_RUN=1` still emits the cache
JSON dump (regression check)
- [x] `/review-codex` pass clean after iterating on three findings
(packed-refs fix already on `main` via #2697 once branch was rebased;
`BatchSize::PerIteration` for true per-iter invalidation; `cfg(unix)`
gate for Windows)
> _This was written by Claude Code on behalf of Maximilian Roos_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:22 -07:00
|
|
|
|
// Interactive picker requires a terminal for the TUI. The dry-run and
|
|
|
|
|
|
// preview-bench paths bypass skim entirely, so no TTY is required —
|
|
|
|
|
|
// useful for tests, for diagnosing the pre-compute pipeline from scripts,
|
|
|
|
|
|
// and for benchmarking the preview workload headlessly.
|
|
|
|
|
|
let is_dry_run = std::env::var_os("WORKTRUNK_PICKER_DRY_RUN").is_some();
|
|
|
|
|
|
let is_preview_bench = std::env::var_os("WORKTRUNK_PREVIEW_BENCH").is_some();
|
|
|
|
|
|
let skip_tui = is_dry_run || is_preview_bench;
|
|
|
|
|
|
if !skip_tui && !std::io::stdin().is_terminal() {
|
2026-02-02 06:05:39 -08:00
|
|
|
|
anyhow::bail!("Interactive picker requires an interactive terminal");
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
}
|
refactor(trace): unify in-process trace-trigger surface (#2554)
Three small consolidations in the trace-trigger surface, following up on
#2539.
## What this changes
**1. `Cmd::stream()` emits `[wt-trace] cmd=...` natively.** Previously
only `Cmd::run()` and `Cmd::pipe_into()` emitted per-subprocess records
— `stream()` was a hole, which #2539 patched with a
`Span::new(\"execute_shell_command\")` wrapper at the foreground
hook/alias call site. The two surrogates aren't equivalent: spans render
under `cat: \"wt\"`, subprocess records under `cat:
\"git\"`/`\"network\"`, and spans don't carry the `ok` flag, so
hook/alias child status was invisible in chrome traces. Stream now emits
a record at every exit point (spawn fail, stdin write, wait fail,
signal-derived exit, SIGPIPE-as-success, non-zero status, success) via a
small `WtTraceLog` helper that mirrors `ExternalCommandLog`'s shape.
**2. Drops the `Span::new(\"execute_shell_command\")` workaround** in
`commands/command_executor.rs`. With `Cmd::stream()` emitting natively,
the wrapper is redundant — foreground hook/alias step time is now
captured by the canonical subprocess record (cat=`git`/`network`/none)
instead of a generic span (cat=`wt`).
**3. Re-exports `trace::instant` from `trace::mod`** alongside `Span`.
Deletes the `shell_exec::trace_instant` shim (a one-line re-export of
`trace::emit::instant`) and migrates all 14 callers in
`commands/picker/mod.rs` and `commands/list/collect/mod.rs` to
`worktrunk::trace::instant`. Symmetric public API: `trace::Span` for
scopes, `trace::instant` for milestones — neither lives under
`shell_exec` anymore, since neither has anything to do with shell
execution.
## Verification
Smoke-tested with `RUST_LOG=debug wt <alias>`: `cmd=\"echo
hello-from-stream\" ok=true` and `cmd=\"exit 7\" ok=false` both fire
correctly. `Span(\"execute_shell_command\")` no longer appears in the
trace. Full pre-merge hook (3430 tests + clippy + lints) passes locally.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:14:19 -07:00
|
|
|
|
worktrunk::trace::instant("Picker started");
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
2026-02-22 05:44:49 -08:00
|
|
|
|
let (repo, is_recovered) = current_or_recover()?;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
2026-03-13 22:42:40 +08:00
|
|
|
|
// Merge CLI flags with resolved config (project-specific config is now available)
|
2026-02-16 14:37:09 -08:00
|
|
|
|
let config = repo.config();
|
2026-03-31 21:44:51 -07:00
|
|
|
|
let change_dir = change_dir_flag.unwrap_or_else(|| config.switch.cd());
|
2026-02-16 14:37:09 -08:00
|
|
|
|
let show_branches = cli_branches || config.list.branches();
|
|
|
|
|
|
let show_remotes = cli_remotes || config.list.remotes();
|
2026-06-22 16:34:04 -07:00
|
|
|
|
// Flag-only: listing PRs always reaches the forge, so it stays opt-in
|
|
|
|
|
|
// per invocation rather than defaulting on via config.
|
|
|
|
|
|
let show_prs = cli_prs;
|
refactor(trace): unify in-process trace-trigger surface (#2554)
Three small consolidations in the trace-trigger surface, following up on
#2539.
## What this changes
**1. `Cmd::stream()` emits `[wt-trace] cmd=...` natively.** Previously
only `Cmd::run()` and `Cmd::pipe_into()` emitted per-subprocess records
— `stream()` was a hole, which #2539 patched with a
`Span::new(\"execute_shell_command\")` wrapper at the foreground
hook/alias call site. The two surrogates aren't equivalent: spans render
under `cat: \"wt\"`, subprocess records under `cat:
\"git\"`/`\"network\"`, and spans don't carry the `ok` flag, so
hook/alias child status was invisible in chrome traces. Stream now emits
a record at every exit point (spawn fail, stdin write, wait fail,
signal-derived exit, SIGPIPE-as-success, non-zero status, success) via a
small `WtTraceLog` helper that mirrors `ExternalCommandLog`'s shape.
**2. Drops the `Span::new(\"execute_shell_command\")` workaround** in
`commands/command_executor.rs`. With `Cmd::stream()` emitting natively,
the wrapper is redundant — foreground hook/alias step time is now
captured by the canonical subprocess record (cat=`git`/`network`/none)
instead of a generic span (cat=`wt`).
**3. Re-exports `trace::instant` from `trace::mod`** alongside `Span`.
Deletes the `shell_exec::trace_instant` shim (a one-line re-export of
`trace::emit::instant`) and migrates all 14 callers in
`commands/picker/mod.rs` and `commands/list/collect/mod.rs` to
`worktrunk::trace::instant`. Symmetric public API: `trace::Span` for
scopes, `trace::instant` for milestones — neither lives under
`shell_exec` anymore, since neither has anything to do with shell
execution.
## Verification
Smoke-tested with `RUST_LOG=debug wt <alias>`: `cmd=\"echo
hello-from-stream\" ok=true` and `cmd=\"exit 7\" ok=false` both fire
correctly. `Span(\"execute_shell_command\")` no longer appears in the
trace. Full pre-merge hook (3430 tests + clippy + lints) passes locally.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:14:19 -07:00
|
|
|
|
worktrunk::trace::instant("Picker config resolved");
|
2026-02-16 14:37:09 -08:00
|
|
|
|
|
refactor(picker): read terminal size once from a canonical reader (#3219)
PR #3210 collapsed the picker's repeated `terminal_size()` reads into
one, but left the startup path reading dimensions from two different
sources: the layout sizing used `terminal_size::terminal_size()`
(stdout-first, `(80, 24)` fallback) while `skim_list_width` used
`display::terminal_width()` (stderr-first, `COLUMNS` fallback). On a
terminal where stdout and stderr point to different places, the preview
sizing and the list-column width could observe different widths.
This adds `terminal_dimensions() -> Option<(usize, Option<usize>)>` in
`styling/mod.rs` as the single canonical reader, with the existing probe
chain: stderr, then stdout, then `COLUMNS`. `terminal_width()` becomes
its width projection (`terminal_dimensions().map(|(w, _)| w)`), so its
external contract and all six other callers are untouched. The height is
`Option` because `COLUMNS` supplies a width with no height counterpart.
The picker reads the canonical source once as `term_dims` and derives
both the layout and `skim_list_width` from that one snapshot.
## The height carries the "real terminal detected" signal
The layout needs both width and height, so it trusts the snapshot only
when a real terminal supplied both — `Some((w, Some(h)))`. A width-only
`COLUMNS` reading (`Some((w, None))`), or no reading at all, falls back
to `80x24` for the layout, exactly as the old stdout-only read did.
`skim_list_width` needs only a width, so it still uses the `COLUMNS`
width via the same snapshot. This resolves the `COLUMNS`/height
asymmetry cleanly: the same read serves both callers, the
`Option<height>` is load-bearing, and the prior fallback behavior is
preserved.
## Behavior
For a real terminal — the picker requires a TTY, so the normal case —
both the layout and `skim_list_width` see the detected dimensions, so
the change is a no-op. `terminal_width()` is byte-identical for every
input, leaving its callers (`progress`, `help`, `styling::format`,
`commands::mod`, `list::layout`, `list::collect`) unaffected. The one
observable change is the rare split where stdout is not a TTY but stderr
is: the layout now tracks the real terminal via the stderr probe
(matching `skim_list_width` and what skim renders on) instead of falling
back to `80x24`.
`list::progressive_table` keeps its own stdout-only height probe: `wt
list` renders to stdout, so it must detect a real stdout TTY rather than
a stderr/`COLUMNS` fallback; folding it into the stderr-first reader
would change its behavior, so it stays separate.
This branch also merges `main`, reconciling with #3214 (the picker now
lays its table out at full width regardless of preview layout):
`skim_list_width` takes the full width from the same `term_dims`
snapshot, dropping the obsolete Right/Down split.
Both layout arms and the width derivation are covered by the existing
PTY picker tests (real terminal) and dry-run tests (`COLUMNS`-only
fallback); two unit tests lock the `terminal_width` ↔
`terminal_dimensions` delegation. The full local gate is green apart
from `test_switch_picker_alt_l_does_not_hscroll`, a picker PTY snapshot
that is environment-sensitive on macOS (it fails the same way on `main`
locally) and passes on CI — the change is output-neutral for it.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:29:17 -07:00
|
|
|
|
// Read the terminal size once, from the canonical reader that
|
|
|
|
|
|
// `crate::display::terminal_width` also projects (stderr first, then stdout,
|
|
|
|
|
|
// then `COLUMNS`). The skim list-column width (`skim_list_width` below)
|
|
|
|
|
|
// derives from the same snapshot, so the two can never observe different
|
|
|
|
|
|
// widths — whether across a resize or because stdout and stderr point to
|
|
|
|
|
|
// different terminals.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The layout needs both dimensions, so it trusts the snapshot only when a
|
|
|
|
|
|
// real terminal was detected (width and height both present). A width-only
|
|
|
|
|
|
// `COLUMNS` reading — or no reading at all — falls back to 80x24, exactly as
|
|
|
|
|
|
// before. The picker requires a TTY, so that fallback only bites the
|
|
|
|
|
|
// headless dry-run / preview-bench paths; `skim_list_width` still uses the
|
|
|
|
|
|
// `COLUMNS` width there.
|
|
|
|
|
|
let term_dims = crate::display::terminal_dimensions();
|
|
|
|
|
|
let (term_width, term_height) = match term_dims {
|
|
|
|
|
|
Some((w, Some(h))) => (w, h),
|
|
|
|
|
|
_ => (80, 24),
|
|
|
|
|
|
};
|
refactor(picker): read terminal size once for layout sizing (#3210)
## What
Collapses the interactive picker's repeated `terminal_size()` reads into
a single read, threaded explicitly through the layout-sizing code.
## Why
PR #3205 made the picker's Down-layout list height adapt to the
terminal, but left the startup path reading the terminal size 3–4 times
per launch — once in `auto_detect` (layout), once for the
`num_items_estimate` cap, once each inside `to_preview_window_spec` and
`preview_dimensions`, once for the speculative pre-compute, and once for
`half_page`. `to_preview_window_spec` re-read the terminal and
recomputed the Down spec internally, so the Down preview dimensions were
computed twice. Beyond the redundant syscalls, the estimate cap and the
actual layout could observe different terminal sizes if the window was
resized mid-startup — a benign but real race.
## How
`handle_picker` now reads `terminal_size::terminal_size()` once and
threads `(term_width, term_height)` into every sizing site: layout
detection (`PreviewLayout::for_dimensions`), the visible-row cap
(`max_visible_items(available_height(term_height))`), the preview
dimensions (`dimensions_for`), the speculative pre-compute, and the
half-page scroll. `dimensions_for` — already pure and unit-tested — is
the single entry; `spec_for` formats the skim preview-window spec from
the already-computed dims rather than recomputing them.
This retires three terminal-reading methods on `PreviewLayout`:
`auto_detect` (folded into the single read + `for_dimensions`),
`preview_dimensions` (the live-terminal reader), and
`to_preview_window_spec` (which re-read and recomputed). `preview.rs` no
longer reads the terminal at all — the read lives solely in
`handle_picker`. `crate::display::terminal_width()` (a separate
stderr-first width probe for the skim list column) is left as-is; it
isn't part of the layout-sizing path.
## Behavior
No user-facing change. Fallbacks are preserved at every site — the
single read falls back to `(80, 24)`, matching the prior per-call
fallbacks, and `half_page` on that fallback still evaluates to `10`
(`(available_height(24) / 2).max(5)` = `(21 / 2).max(5)` = `10`),
identical to the old `.unwrap_or(10)`. The pre-existing `dimensions_for`
scenario/edge tests pass unchanged; the one spec-formatting test was
retargeted at `spec_for` with exact-string assertions (strictly
stronger), and a redundant duplicate of it in `mod.rs` was removed.
`cargo run -- hook pre-merge --yes` is green (4181 tests, clippy, fmt).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:29:21 -07:00
|
|
|
|
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
// Reset the preview tab to working-tree and select the layout from the
|
|
|
|
|
|
// terminal size.
|
refactor(picker): read terminal size once for layout sizing (#3210)
## What
Collapses the interactive picker's repeated `terminal_size()` reads into
a single read, threaded explicitly through the layout-sizing code.
## Why
PR #3205 made the picker's Down-layout list height adapt to the
terminal, but left the startup path reading the terminal size 3–4 times
per launch — once in `auto_detect` (layout), once for the
`num_items_estimate` cap, once each inside `to_preview_window_spec` and
`preview_dimensions`, once for the speculative pre-compute, and once for
`half_page`. `to_preview_window_spec` re-read the terminal and
recomputed the Down spec internally, so the Down preview dimensions were
computed twice. Beyond the redundant syscalls, the estimate cap and the
actual layout could observe different terminal sizes if the window was
resized mid-startup — a benign but real race.
## How
`handle_picker` now reads `terminal_size::terminal_size()` once and
threads `(term_width, term_height)` into every sizing site: layout
detection (`PreviewLayout::for_dimensions`), the visible-row cap
(`max_visible_items(available_height(term_height))`), the preview
dimensions (`dimensions_for`), the speculative pre-compute, and the
half-page scroll. `dimensions_for` — already pure and unit-tested — is
the single entry; `spec_for` formats the skim preview-window spec from
the already-computed dims rather than recomputing them.
This retires three terminal-reading methods on `PreviewLayout`:
`auto_detect` (folded into the single read + `for_dimensions`),
`preview_dimensions` (the live-terminal reader), and
`to_preview_window_spec` (which re-read and recomputed). `preview.rs` no
longer reads the terminal at all — the read lives solely in
`handle_picker`. `crate::display::terminal_width()` (a separate
stderr-first width probe for the skim list column) is left as-is; it
isn't part of the layout-sizing path.
## Behavior
No user-facing change. Fallbacks are preserved at every site — the
single read falls back to `(80, 24)`, matching the prior per-call
fallbacks, and `half_page` on that fallback still evaluates to `10`
(`(available_height(24) / 2).max(5)` = `(21 / 2).max(5)` = `10`),
identical to the old `.unwrap_or(10)`. The pre-existing `dimensions_for`
scenario/edge tests pass unchanged; the one spec-formatting test was
retargeted at `spec_for` with exact-string assertions (strictly
stronger), and a redundant duplicate of it in `mod.rs` was removed.
`cargo run -- hook pre-merge --yes` is green (4181 tests, clippy, fmt).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:29:21 -07:00
|
|
|
|
let state = PreviewState::new(PreviewLayout::for_dimensions(
|
|
|
|
|
|
term_width as f64,
|
|
|
|
|
|
term_height as f64,
|
|
|
|
|
|
));
|
refactor(trace): unify in-process trace-trigger surface (#2554)
Three small consolidations in the trace-trigger surface, following up on
#2539.
## What this changes
**1. `Cmd::stream()` emits `[wt-trace] cmd=...` natively.** Previously
only `Cmd::run()` and `Cmd::pipe_into()` emitted per-subprocess records
— `stream()` was a hole, which #2539 patched with a
`Span::new(\"execute_shell_command\")` wrapper at the foreground
hook/alias call site. The two surrogates aren't equivalent: spans render
under `cat: \"wt\"`, subprocess records under `cat:
\"git\"`/`\"network\"`, and spans don't carry the `ok` flag, so
hook/alias child status was invisible in chrome traces. Stream now emits
a record at every exit point (spawn fail, stdin write, wait fail,
signal-derived exit, SIGPIPE-as-success, non-zero status, success) via a
small `WtTraceLog` helper that mirrors `ExternalCommandLog`'s shape.
**2. Drops the `Span::new(\"execute_shell_command\")` workaround** in
`commands/command_executor.rs`. With `Cmd::stream()` emitting natively,
the wrapper is redundant — foreground hook/alias step time is now
captured by the canonical subprocess record (cat=`git`/`network`/none)
instead of a generic span (cat=`wt`).
**3. Re-exports `trace::instant` from `trace::mod`** alongside `Span`.
Deletes the `shell_exec::trace_instant` shim (a one-line re-export of
`trace::emit::instant`) and migrates all 14 callers in
`commands/picker/mod.rs` and `commands/list/collect/mod.rs` to
`worktrunk::trace::instant`. Symmetric public API: `trace::Span` for
scopes, `trace::instant` for milestones — neither lives under
`shell_exec` anymore, since neither has anything to do with shell
execution.
## Verification
Smoke-tested with `RUST_LOG=debug wt <alias>`: `cmd=\"echo
hello-from-stream\" ok=true` and `cmd=\"exit 7\" ok=false` both fire
correctly. `Span(\"execute_shell_command\")` no longer appears in the
trace. Full pre-merge hook (3430 tests + clippy + lints) passes locally.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:14:19 -07:00
|
|
|
|
worktrunk::trace::instant("Picker layout detected");
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
2026-05-02 11:46:55 -07:00
|
|
|
|
// Prime the current worktree's root / git-dir / branch caches with one
|
|
|
|
|
|
// batched `git rev-parse`. Subsumes the two standalone forks that the
|
|
|
|
|
|
// speculative preview block below would otherwise make via `branch()`
|
2026-04-21 20:21:02 -07:00
|
|
|
|
// and `root()`, and is also short-circuited when `collect::collect` calls
|
|
|
|
|
|
// `repo.url_template()` → `load_project_config()` → `project_config_path()`
|
|
|
|
|
|
// (which runs `prewarm_info` again — now a cache hit).
|
|
|
|
|
|
let _ = repo.current_worktree().prewarm_info();
|
|
|
|
|
|
|
2026-05-09 23:43:07 -07:00
|
|
|
|
// Preview cache is created up-front so the speculative first-item
|
|
|
|
|
|
// preview can run in parallel with `collect::collect` below. Tasks
|
fix(picker): stop first-keystroke freeze (#3087)
Fixes a multi-second freeze in the `wt switch` picker: with many
accumulated worktrees, typing the first character locks the UI for
seconds, then it recovers. The freeze scales with worktree count.
The picker (skim) runs its per-keystroke fuzzy matcher and result sort
on rayon's **global** thread pool. Worktrunk's collection floods that
same global pool with blocking git subprocess tasks (status, diff,
rev-list, merge-base, plus the preview orchestrator's per-mode `git
diff` / `git log`), one batch per worktree. The global pool has only `2×
CPU` workers, and each git call blocks its worker for the subprocess
lifetime. When the user types, skim's matcher queues behind that flood
and can't run until workers drain. `wt list` never froze because nothing
else contends for the pool there.
The fix moves the git-heavy collection and preview work onto a dedicated
`COLLECT_POOL`, leaving the global pool free for skim. This is the same
isolation pattern already used by `copy::COPY_POOL` and
`remove_dir::REMOVE_POOL`. The new pool is sized like the global pool
(`2× CPU`, honoring `RAYON_NUM_THREADS`), so collection throughput is
unchanged. Its only job is to keep the git work off the pool skim's
matcher uses.
## Decisions
- Collection's row pipeline and the preview orchestrator stay on the
same dedicated pool, preserving the orchestrator's intentional "one
shared pool, let workers prefer dominant pressure" design. They just
move off the pool skim needs.
- The bounded pre- and post-skeleton `rayon::scope` calls stay on the
global pool. They are O(1) in worktree count (~7 spawns), so they are
not the scaling flood.
- The single-item statusline path (`populate_item`) routes through
`COLLECT_POOL` too, purely for consistency. A single item never floods
the pool, so this path was never the problem.
- The nested log-refresh `rayon::spawn_fifo` needs no change. The free
`rayon::spawn_fifo` resolves its target via the current worker's
registry, so when called from inside a `COLLECT_POOL` worker it inherits
`COLLECT_POOL` rather than falling back to the global pool. Confirmed
against the rayon-core source.
## Testing
Ran locally, before and after below, depicting the freeze/fix
### Before
https://github.com/user-attachments/assets/55c12cec-4466-487a-b480-fd2ff03ad111
### After
https://github.com/user-attachments/assets/71a5499a-5521-4727-a6b4-ab6a12f317d5
2026-06-15 19:43:45 -07:00
|
|
|
|
// route to `COLLECT_POOL` (shared with the row pipeline).
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
// Wrapped in `Arc` because the progressive handler (running on the
|
|
|
|
|
|
// collect background thread) also calls `spawn_preview`.
|
2026-05-09 16:25:53 -07:00
|
|
|
|
//
|
2026-06-28 20:01:44 -07:00
|
|
|
|
// BranchDiff previews resolve the upstream-aware comparison base via
|
|
|
|
|
|
// `Repository::branch_diff_spec`, memoized on the shared `RepoCache` and
|
|
|
|
|
|
// primed by `collect::collect` from its ref scan, so N parallel preview
|
|
|
|
|
|
// tasks share one resolution instead of each capturing refs. Read-only for
|
|
|
|
|
|
// the picker session — see `branch_diff_spec` for the staleness contract.
|
fix(switch): auto-refresh picker preview when a background compute lands (#3247)
## Problem
The `wt switch` picker's preview pane is served from a `DashMap` cache
filled by background workers on `COLLECT_POOL` (a `git diff HEAD`, a
`git log`, a forge `gh pr view`). skim 4.8 re-reads that cache **only**
inside `run_preview`, which fires only on `Event::RunPreview` — produced
by a selection change or a preview-tab keystroke. The cache-insert path
didn't poke skim, so a compute that finished *after* the single
`RunPreview` the keystroke produced sat in the cache with no event to
surface it: the pane stayed on its `Loading…` placeholder until the user
pressed a key again. That `Press alt-N again to refresh` text was the
manual workaround for exactly this gap, and it was a Windows-CI flake
(PR #3238 papered over it test-side by re-issuing the tab keystroke).
## The skim mechanism this uses
skim 4.8 hands the embedder its event sender at TUI init:
`Skim::event_sender()` returns the `tokio::sync::mpsc::Sender<Event>`
that drives the loop. The picker already captures it (as `render_tx`, a
shared `Arc<OnceLock<…>>`) and pushes `Event::Render` through it for
in-place row repaints. **Pushing `Event::RunPreview` through the same
channel forces `run_preview` to re-read the cache for the
currently-selected row + current mode** — the external injection point
the orchestrator needed. The channel is `1024*1024`-capacity, so the
`try_send` poke is never dropped.
## Approach
New `PreviewNotifier` (`src/commands/picker/preview_notify.rs`) closes
the producer → consumer loop:
- **Consumer side:** every `*SkimItem::preview()` records the selected
row's awaited `(row-key, mode)` via `note_awaiting` — *before* it reads
the cache. That ordering makes the hand-off race-free: if the read
misses, the fill that satisfies it necessarily lands after the read, so
it observes the awaited key already set.
- **Producer side:** the orchestrator routes **every** cache fill
through a single `PreviewOrchestrator::fill` / `fill_external` path,
which calls `notify_filled(key)`. That injects `Event::RunPreview`
**iff** the filled key matches what the selected row is awaiting. A fill
for an off-screen row or a tab the user isn't on matches nothing and
injects nothing — so background pre-compute never thrashes the visible
preview.
`preview()` is only ever called for the selected row, so the single
shared `awaiting` slot always reflects what's on screen; when the
selection changes, the next `RunPreview` updates it.
Two producers feed the panes, both now covered:
- **Orchestrator cache fills** (diff / log / summary / the `--prs`
comments & log fetch) → `notify_filled(key)`, exact-key match.
- **The collect handler's `on_update`** mirrors a row's live `pr_status`
(the `pr` / `comments` panes) and `local_content` (the diff tabs' dim
state) — not cache fills → `notify_row_changed(row_key)`, which re-runs
the selected row's preview on *any* tab when that row's data lands. This
is what flips the `pr` tab from "Fetching PR status…" to the resolved PR
on its own.
Wiring: `render_tx` is constructed before the orchestrator in
`handle_picker` and handed in; it's still published once, inside
`run_skim` after `init_tui`. `generate_and_cache_summary` became
`generate_summary_for_item` (returns the pane; the orchestrator inserts
via `fill`).
## User-visible change
The placeholders drop the now-obsolete "press alt-N … to refresh"
wording — the pane fills in on its own:
```
○ Loading working-tree diff… (was: ○ Loading working-tree diff. Press alt-1 again to refresh.)
○ Generating summary…
○ Fetching PR status for feature… (was: … press alt-6 to refresh)
ⓘ Loading comments… (--prs deferred tabs; was: … press alt-2 again to refresh)
```
## Testing
- `test_switch_picker_preview_auto_refreshes_when_compute_lands` (PTY):
mocks `gh pr view --json comments` behind a 3 s delay, opens a `--prs`
row's comments tab mid-fetch — the comment surfaces **with no further
input** (the orchestrator-fill path).
- `test_switch_picker_pr_tab_auto_resolves_from_fetching` (PTY): the
per-row CI fetch (`gh pr list --head`) is delayed 3 s and unseeded, so
the `pr` tab opens on "Fetching PR status…" and resolves to the live PR
on its own (the `on_update` path).
- Both verified to **time out (stay stranded) when the poke is
disabled**, so they genuinely exercise the mechanism rather than passing
on a cache hit.
- `fill_notifies_only_awaited_key` /
`notify_row_changed_pokes_only_the_selected_row` (unit): the poke fires
for the visible row+mode (resp. row, any mode) and nothing for
off-screen / other-tab keys — the no-thrash guarantee.
- The PTY driver's keystroke re-issue (`nudge` / `is_alt_digit_tab` /
`PREVIEW_REISSUE_INTERVAL`, PR #3238) is removed — the product now
auto-refreshes, so the tests genuinely verify it rather than papering
over a strand. All 37 `switch_picker` PTY tests pass; full `cargo run --
hook pre-merge --yes` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:24:23 -07:00
|
|
|
|
//
|
|
|
|
|
|
// skim 4.x repaints on demand, so the orchestrator needs a handle to skim's
|
|
|
|
|
|
// event loop to surface a preview compute that lands after the keystroke that
|
|
|
|
|
|
// requested it. The picker fills this `OnceLock` once `Skim::init_tui` has run
|
|
|
|
|
|
// (inside `run_skim`); until then a fill simply doesn't poke (harmless — skim
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// hasn't rendered a preview to strand yet). The progressive handler and a
|
|
|
|
|
|
// failed-removal restore share the same sender for their own `Event::Render` /
|
|
|
|
|
|
// resync pokes. See `preview_notify` and the `progressive_handler` module
|
fix(switch): auto-refresh picker preview when a background compute lands (#3247)
## Problem
The `wt switch` picker's preview pane is served from a `DashMap` cache
filled by background workers on `COLLECT_POOL` (a `git diff HEAD`, a
`git log`, a forge `gh pr view`). skim 4.8 re-reads that cache **only**
inside `run_preview`, which fires only on `Event::RunPreview` — produced
by a selection change or a preview-tab keystroke. The cache-insert path
didn't poke skim, so a compute that finished *after* the single
`RunPreview` the keystroke produced sat in the cache with no event to
surface it: the pane stayed on its `Loading…` placeholder until the user
pressed a key again. That `Press alt-N again to refresh` text was the
manual workaround for exactly this gap, and it was a Windows-CI flake
(PR #3238 papered over it test-side by re-issuing the tab keystroke).
## The skim mechanism this uses
skim 4.8 hands the embedder its event sender at TUI init:
`Skim::event_sender()` returns the `tokio::sync::mpsc::Sender<Event>`
that drives the loop. The picker already captures it (as `render_tx`, a
shared `Arc<OnceLock<…>>`) and pushes `Event::Render` through it for
in-place row repaints. **Pushing `Event::RunPreview` through the same
channel forces `run_preview` to re-read the cache for the
currently-selected row + current mode** — the external injection point
the orchestrator needed. The channel is `1024*1024`-capacity, so the
`try_send` poke is never dropped.
## Approach
New `PreviewNotifier` (`src/commands/picker/preview_notify.rs`) closes
the producer → consumer loop:
- **Consumer side:** every `*SkimItem::preview()` records the selected
row's awaited `(row-key, mode)` via `note_awaiting` — *before* it reads
the cache. That ordering makes the hand-off race-free: if the read
misses, the fill that satisfies it necessarily lands after the read, so
it observes the awaited key already set.
- **Producer side:** the orchestrator routes **every** cache fill
through a single `PreviewOrchestrator::fill` / `fill_external` path,
which calls `notify_filled(key)`. That injects `Event::RunPreview`
**iff** the filled key matches what the selected row is awaiting. A fill
for an off-screen row or a tab the user isn't on matches nothing and
injects nothing — so background pre-compute never thrashes the visible
preview.
`preview()` is only ever called for the selected row, so the single
shared `awaiting` slot always reflects what's on screen; when the
selection changes, the next `RunPreview` updates it.
Two producers feed the panes, both now covered:
- **Orchestrator cache fills** (diff / log / summary / the `--prs`
comments & log fetch) → `notify_filled(key)`, exact-key match.
- **The collect handler's `on_update`** mirrors a row's live `pr_status`
(the `pr` / `comments` panes) and `local_content` (the diff tabs' dim
state) — not cache fills → `notify_row_changed(row_key)`, which re-runs
the selected row's preview on *any* tab when that row's data lands. This
is what flips the `pr` tab from "Fetching PR status…" to the resolved PR
on its own.
Wiring: `render_tx` is constructed before the orchestrator in
`handle_picker` and handed in; it's still published once, inside
`run_skim` after `init_tui`. `generate_and_cache_summary` became
`generate_summary_for_item` (returns the pane; the orchestrator inserts
via `fill`).
## User-visible change
The placeholders drop the now-obsolete "press alt-N … to refresh"
wording — the pane fills in on its own:
```
○ Loading working-tree diff… (was: ○ Loading working-tree diff. Press alt-1 again to refresh.)
○ Generating summary…
○ Fetching PR status for feature… (was: … press alt-6 to refresh)
ⓘ Loading comments… (--prs deferred tabs; was: … press alt-2 again to refresh)
```
## Testing
- `test_switch_picker_preview_auto_refreshes_when_compute_lands` (PTY):
mocks `gh pr view --json comments` behind a 3 s delay, opens a `--prs`
row's comments tab mid-fetch — the comment surfaces **with no further
input** (the orchestrator-fill path).
- `test_switch_picker_pr_tab_auto_resolves_from_fetching` (PTY): the
per-row CI fetch (`gh pr list --head`) is delayed 3 s and unseeded, so
the `pr` tab opens on "Fetching PR status…" and resolves to the live PR
on its own (the `on_update` path).
- Both verified to **time out (stay stranded) when the poke is
disabled**, so they genuinely exercise the mechanism rather than passing
on a cache hit.
- `fill_notifies_only_awaited_key` /
`notify_row_changed_pokes_only_the_selected_row` (unit): the poke fires
for the visible row+mode (resp. row, any mode) and nothing for
off-screen / other-tab keys — the no-thrash guarantee.
- The PTY driver's keystroke re-issue (`nudge` / `is_alt_digit_tab` /
`PREVIEW_REISSUE_INTERVAL`, PR #3238) is removed — the product now
auto-refreshes, so the tests genuinely verify it rather than papering
over a strand. All 37 `switch_picker` PTY tests pass; full `cargo run --
hook pre-merge --yes` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:24:23 -07:00
|
|
|
|
// docstring.
|
|
|
|
|
|
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>> = Arc::new(OnceLock::new());
|
|
|
|
|
|
let orchestrator = Arc::new(PreviewOrchestrator::new(
|
|
|
|
|
|
repo.clone(),
|
|
|
|
|
|
Arc::clone(&render_tx),
|
|
|
|
|
|
));
|
Unblock picker first render; add preview dry-run (#2210)
## Problem
On repos with many worktrees, `wt switch` shows a blank terminal for 1–2
seconds before the list appears. Skim 0.20's event loop calls
`SkimItem::preview()` synchronously before `term.draw()`
(`model/mod.rs:715-722`) — any latency inside `preview()` freezes the
whole UI, not just the preview pane. The previous implementation held a
DashMap shard write lock across a git + pager subprocess via
`entry().or_insert_with(...)`, so skim's first render blocked behind
whichever background task was currently computing the first item's
default mode.
## Changes
**Thread pool** (first commit, already reviewed upstream): dedicated
rayon pool for preview/summary pre-compute, sized `2×cores` to match the
global pool's mixed-I/O profile. Extracted `rayon_thread_count()` so the
two sites can't drift.
**Non-blocking `preview()`**: `preview_for_mode` is now a pure cache
read — hit returns content, miss returns a mode-specific placeholder
(`"○ Loading working-tree diff. Press 1 again to refresh."`). Background
tasks compute outside any DashMap lock and use `insert` after, matching
the pattern `generate_and_cache_summary` already used for LLM summaries.
Skim 0.20 doesn't expose a way to re-query preview without user
interaction (`on_item_change` at `previewer.rs:187` bails on unchanged
items), so the placeholder's "press N again" instruction is the
supported refresh path.
**`PreviewOrchestrator`**
(`src/commands/picker/preview_orchestrator.rs`): owns the cache,
dedicated pool, and a pending-task counter. `PendingGuard` decrements on
drop so a panicking task still releases the counter — otherwise
`wait_for_idle` would hang forever on any panic. Exposes
`spawn_preview`, `spawn_summary`, `wait_for_idle`, `dump_cache_json` so
the pipeline is testable without skim.
**`WORKTRUNK_PICKER_DRY_RUN`**: setting the env var runs the full
pre-compute (speculative first-item spawn, collect, full spawn loop,
summaries), waits for all tasks, prints cache inventory as JSON, and
exits instead of launching skim. Useful for diagnosing "previews never
load" bugs from scripts and as the basis for integration tests.
## Testing
Unit tests in `preview_orchestrator.rs` cover end-to-end cache
population (via real `TestRepo` + git subprocesses, no mocks),
duplicate-spawn short-circuiting, and the JSON dump format.
Verified by running `WORKTRUNK_PICKER_DRY_RUN=1 wt switch` in this repo:
14 branches × 5 modes = 70 entries, all non-empty, 5s to full cache
warm.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 22:16:42 -07:00
|
|
|
|
let preview_cache: PreviewCache = Arc::clone(&orchestrator.cache);
|
|
|
|
|
|
|
|
|
|
|
|
// Speculative warm-up: the picker sorts the current worktree first, and
|
|
|
|
|
|
// the default tab (WorkingTree = `git diff HEAD` in that worktree) is
|
|
|
|
|
|
// what skim will render first. Kicking this off before `collect::collect`
|
2026-04-15 08:59:05 -07:00
|
|
|
|
// overlaps preview compute with list collection.
|
Unblock picker first render; add preview dry-run (#2210)
## Problem
On repos with many worktrees, `wt switch` shows a blank terminal for 1–2
seconds before the list appears. Skim 0.20's event loop calls
`SkimItem::preview()` synchronously before `term.draw()`
(`model/mod.rs:715-722`) — any latency inside `preview()` freezes the
whole UI, not just the preview pane. The previous implementation held a
DashMap shard write lock across a git + pager subprocess via
`entry().or_insert_with(...)`, so skim's first render blocked behind
whichever background task was currently computing the first item's
default mode.
## Changes
**Thread pool** (first commit, already reviewed upstream): dedicated
rayon pool for preview/summary pre-compute, sized `2×cores` to match the
global pool's mixed-I/O profile. Extracted `rayon_thread_count()` so the
two sites can't drift.
**Non-blocking `preview()`**: `preview_for_mode` is now a pure cache
read — hit returns content, miss returns a mode-specific placeholder
(`"○ Loading working-tree diff. Press 1 again to refresh."`). Background
tasks compute outside any DashMap lock and use `insert` after, matching
the pattern `generate_and_cache_summary` already used for LLM summaries.
Skim 0.20 doesn't expose a way to re-query preview without user
interaction (`on_item_change` at `previewer.rs:187` bails on unchanged
items), so the placeholder's "press N again" instruction is the
supported refresh path.
**`PreviewOrchestrator`**
(`src/commands/picker/preview_orchestrator.rs`): owns the cache,
dedicated pool, and a pending-task counter. `PendingGuard` decrements on
drop so a panicking task still releases the counter — otherwise
`wait_for_idle` would hang forever on any panic. Exposes
`spawn_preview`, `spawn_summary`, `wait_for_idle`, `dump_cache_json` so
the pipeline is testable without skim.
**`WORKTRUNK_PICKER_DRY_RUN`**: setting the env var runs the full
pre-compute (speculative first-item spawn, collect, full spawn loop,
summaries), waits for all tasks, prints cache inventory as JSON, and
exits instead of launching skim. Useful for diagnosing "previews never
load" bugs from scripts and as the basis for integration tests.
## Testing
Unit tests in `preview_orchestrator.rs` cover end-to-end cache
population (via real `TestRepo` + git subprocesses, no mocks),
duplicate-spawn short-circuiting, and the JSON dump format.
Verified by running `WORKTRUNK_PICKER_DRY_RUN=1 wt switch` in this repo:
14 branches × 5 modes = 70 entries, all non-empty, 5s to full cache
warm.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 22:16:42 -07:00
|
|
|
|
// The real spawn later skips this key via `contains_key`.
|
|
|
|
|
|
if let (Ok(Some(branch)), Ok(path)) = (
|
|
|
|
|
|
repo.current_worktree().branch(),
|
|
|
|
|
|
repo.current_worktree().root(),
|
|
|
|
|
|
) {
|
|
|
|
|
|
use super::list::model::{ItemKind, ListItem, WorktreeData};
|
|
|
|
|
|
let mut item = ListItem::new_branch(String::new(), branch);
|
|
|
|
|
|
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
|
|
|
|
|
|
path,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}));
|
|
|
|
|
|
// num_items doesn't matter for Right (dims independent of it); for
|
|
|
|
|
|
// Down it only affects height, which doesn't alter pager wrapping.
|
refactor(picker): read terminal size once for layout sizing (#3210)
## What
Collapses the interactive picker's repeated `terminal_size()` reads into
a single read, threaded explicitly through the layout-sizing code.
## Why
PR #3205 made the picker's Down-layout list height adapt to the
terminal, but left the startup path reading the terminal size 3–4 times
per launch — once in `auto_detect` (layout), once for the
`num_items_estimate` cap, once each inside `to_preview_window_spec` and
`preview_dimensions`, once for the speculative pre-compute, and once for
`half_page`. `to_preview_window_spec` re-read the terminal and
recomputed the Down spec internally, so the Down preview dimensions were
computed twice. Beyond the redundant syscalls, the estimate cap and the
actual layout could observe different terminal sizes if the window was
resized mid-startup — a benign but real race.
## How
`handle_picker` now reads `terminal_size::terminal_size()` once and
threads `(term_width, term_height)` into every sizing site: layout
detection (`PreviewLayout::for_dimensions`), the visible-row cap
(`max_visible_items(available_height(term_height))`), the preview
dimensions (`dimensions_for`), the speculative pre-compute, and the
half-page scroll. `dimensions_for` — already pure and unit-tested — is
the single entry; `spec_for` formats the skim preview-window spec from
the already-computed dims rather than recomputing them.
This retires three terminal-reading methods on `PreviewLayout`:
`auto_detect` (folded into the single read + `for_dimensions`),
`preview_dimensions` (the live-terminal reader), and
`to_preview_window_spec` (which re-read and recomputed). `preview.rs` no
longer reads the terminal at all — the read lives solely in
`handle_picker`. `crate::display::terminal_width()` (a separate
stderr-first width probe for the skim list column) is left as-is; it
isn't part of the layout-sizing path.
## Behavior
No user-facing change. Fallbacks are preserved at every site — the
single read falls back to `(80, 24)`, matching the prior per-call
fallbacks, and `half_page` on that fallback still evaluates to `10`
(`(available_height(24) / 2).max(5)` = `(21 / 2).max(5)` = `10`),
identical to the old `.unwrap_or(10)`. The pre-existing `dimensions_for`
scenario/edge tests pass unchanged; the one spec-formatting test was
retargeted at `spec_for` with exact-string assertions (strictly
stronger), and a redundant duplicate of it in `mod.rs` was removed.
`cargo run -- hook pre-merge --yes` is green (4181 tests, clippy, fmt).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:29:21 -07:00
|
|
|
|
let dims = state
|
|
|
|
|
|
.initial_layout
|
|
|
|
|
|
.dimensions_for(term_width, term_height, 0);
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
orchestrator.spawn_preview(
|
|
|
|
|
|
&orchestrator.generation(),
|
|
|
|
|
|
Arc::new(item),
|
|
|
|
|
|
PreviewMode::WorkingTree,
|
|
|
|
|
|
dims,
|
|
|
|
|
|
);
|
Unblock picker first render; add preview dry-run (#2210)
## Problem
On repos with many worktrees, `wt switch` shows a blank terminal for 1–2
seconds before the list appears. Skim 0.20's event loop calls
`SkimItem::preview()` synchronously before `term.draw()`
(`model/mod.rs:715-722`) — any latency inside `preview()` freezes the
whole UI, not just the preview pane. The previous implementation held a
DashMap shard write lock across a git + pager subprocess via
`entry().or_insert_with(...)`, so skim's first render blocked behind
whichever background task was currently computing the first item's
default mode.
## Changes
**Thread pool** (first commit, already reviewed upstream): dedicated
rayon pool for preview/summary pre-compute, sized `2×cores` to match the
global pool's mixed-I/O profile. Extracted `rayon_thread_count()` so the
two sites can't drift.
**Non-blocking `preview()`**: `preview_for_mode` is now a pure cache
read — hit returns content, miss returns a mode-specific placeholder
(`"○ Loading working-tree diff. Press 1 again to refresh."`). Background
tasks compute outside any DashMap lock and use `insert` after, matching
the pattern `generate_and_cache_summary` already used for LLM summaries.
Skim 0.20 doesn't expose a way to re-query preview without user
interaction (`on_item_change` at `previewer.rs:187` bails on unchanged
items), so the placeholder's "press N again" instruction is the
supported refresh path.
**`PreviewOrchestrator`**
(`src/commands/picker/preview_orchestrator.rs`): owns the cache,
dedicated pool, and a pending-task counter. `PendingGuard` decrements on
drop so a panicking task still releases the counter — otherwise
`wait_for_idle` would hang forever on any panic. Exposes
`spawn_preview`, `spawn_summary`, `wait_for_idle`, `dump_cache_json` so
the pipeline is testable without skim.
**`WORKTRUNK_PICKER_DRY_RUN`**: setting the env var runs the full
pre-compute (speculative first-item spawn, collect, full spawn loop,
summaries), waits for all tasks, prints cache inventory as JSON, and
exits instead of launching skim. Useful for diagnosing "previews never
load" bugs from scripts and as the basis for integration tests.
## Testing
Unit tests in `preview_orchestrator.rs` cover end-to-end cache
population (via real `TestRepo` + git subprocesses, no mocks),
duplicate-spawn short-circuiting, and the JSON dump format.
Verified by running `WORKTRUNK_PICKER_DRY_RUN=1 wt switch` in this repo:
14 branches × 5 modes = 70 entries, all non-empty, 5s to full cache
warm.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 22:16:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
perf(list): plan background tasks from the columns being rendered (#3274)
## Problem
`[list] columns` filtered purely at the layout layer. A narrowed
selection like `columns = ["branch", "path"]` hid the unselected columns
but still ran every per-worktree git task — `git status`, working/branch
diffs, ahead/behind walks, merge-conflict probes — then threw the
results away. On the kind of repo that motivated #3133 (27 dirty
worktrees) that discarded work is the bulk of the wall-clock cost, so a
"just branch and path" view was no faster than the full table.
This was flagged in the [trace-based diagnosis on the
issue](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4816169750):
`columns = ["branch","path"]` and the default set produced an
**identical** command list. @max-sixty
[confirmed](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4819594461)
it's a bug and asked for the fix.
## Solution
`wt list` decides which background tasks to run in **one canonical
stage**, driven by the columns it will render. The plan flows through
the whole pipeline as a **positive set of tasks to run** — no skip-list,
no inversion, no blanket default.
`collect` computes `tasks` = the union of each rendered column's
`required_tasks()`, gated by the conditions that turn a column off
(`--full`, `[list] summary` + `[commit.generation]`, a url template).
The spawn loop fires exactly that set; the layout renders exactly the
columns it feeds. The rendered set is the `[list] columns` selection for
the table; the picker and `--format json` plan from every column,
because their consumers — the picker's preview tabs, JSON's every-field
contract — need the full data set, not just what renders.
This started as additive pruning layered on the old `skip_tasks`
denylist; review (thanks @max-sixty) pushed it to the canonical,
positive form:
- **One column→task map.** `ColumnSpec::requires_task` is deleted;
`ColumnKind::required_tasks()` is the single source, driving both the
spawn plan and the layout visibility filter (`renders_given_run` — a
column renders iff one of its tasks is in the plan). The two maps can no
longer drift, so the reconciliation test is gone; the `cover_every_task`
drift guard stays and gains teeth (an unconsumed task would never run,
not merely be computed and discarded).
- **A positive run set, end to end.** `CollectOptions` carries `tasks`
(the run set), not a skip set — `collect` threads the plan straight into
the spawn loops, the layout, and `max_pr_number` with no complement
step.
- **No blanket default.** `CollectOptions::for_columns(columns, gates)`
derives the plan; nothing hand-writes a task set. The statusline
declares what it renders (the full column set under full gates, no LLM
summary) instead of leaning on "default everything". The picker rides
`show_full` on `ShowConfig::Resolved`.
- **One mechanism for the summary.** The per-item `SummaryGenerate &&
llm.is_none()` spawn guard is dropped: the column plan is the single
authority on whether the summary runs, and `SummaryGenerateTask` already
returns a clean error on a missing command.
A branch/path `ls` alias over many dirty worktrees now runs no `git
status`, diffs, or ahead/behind walks (#3133), while a column gated off
elsewhere stays off. Behaviour is otherwise unchanged across
default/selection × full/non-full × table/JSON/picker/statusline — no
rendered-output snapshots move (the `help_config_*` snapshots move only
from the columns-doc rewrite).
## Testing
- Planner + filter units: `test_required_tasks_for_render` (the default
set needs every task; a branch/path or custom-column view needs none;
`Status` pulls in every status-feeding task; the gates drop
`ci`/`url`/`summary` even when those columns are explicitly selected)
and `test_renders_given_run` (the "render iff a task is planned" filter,
including `Status` surviving while any signal runs).
- `test_required_tasks_cover_every_task` drift guard retained: the union
of `required_tasks()` across all built-ins equals the full `TaskKind`
set, so no task can fall out of the now-load-bearing map.
- End-to-end: `test_list_config_columns_prune_unused_tasks` (default set
runs `git status --porcelain`; `columns = ["branch", "age"]` runs none)
and `test_list_json_ignores_columns_selection` (`--format json` emits
every field regardless of selection).
- Reviewed by independent finder passes (line-by-line +
removed-behavior, cross-file + picker/JSON equivalence, altitude +
conventions) — no findings; each confirmed the task set is preserved
bit-for-bit. Full `pre-merge` gate (all suites, fmt, clippy, docs-sync,
PTY picker snapshots) green after merging `main`.
Closes #3133
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-06-28 12:05:24 -07:00
|
|
|
|
// The picker runs every task — it is `wt list --full` (`ShowConfig::Resolved`
|
|
|
|
|
|
// forces `show_full`, so `collect` plans the full task set from all columns).
|
|
|
|
|
|
// `main…±` (BranchDiff) is a default `wt list` column, so the picker surfaces
|
|
|
|
|
|
// it too; it's local git keyed by a persistent content-addressed cache, so
|
|
|
|
|
|
// warm rows are instant and a cold row computes once in the background (its
|
|
|
|
|
|
// merge-base walk streams in behind the frame, never blocking the picker).
|
|
|
|
|
|
// CiStatus is primed from the local cache so the first frame shows cached
|
|
|
|
|
|
// status (see `populate_from_cache`), then fetched live and streamed in — the
|
|
|
|
|
|
// same 30–60s-TTL cache plus live fetch as `wt list --full`. The picker's
|
|
|
|
|
|
// lifetime is bounded by the user, so a slow forge call never blocks anything
|
|
|
|
|
|
// (see the "Network Access" notes in CLAUDE.md). The `pr` preview tab reads
|
|
|
|
|
|
// the same live status. `--prs` rows carry their own number from the explicit
|
|
|
|
|
|
// `--prs` forge call.
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
// Per-task command timeout (bounds any single git invocation) from
|
|
|
|
|
|
// shared `[list]` config. Still applies in progressive mode.
|
2026-03-14 01:08:36 -07:00
|
|
|
|
let command_timeout = config.list.task_timeout();
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
// Progressive rendering means the picker never blocks waiting for
|
|
|
|
|
|
// collect — so there's no UI-freeze budget to bound. The drain runs
|
|
|
|
|
|
// until its results channel closes or the fallback DRAIN_TIMEOUT
|
2026-04-14 21:48:49 -07:00
|
|
|
|
// (120s) fires.
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
feat(picker): reclaim table width when the preview toggles (#3214)
## What
The interactive `wt switch` picker now lays its table out at full
terminal width regardless of the preview layout. Previously, in the
side-by-side (Right) layout the table was sized for the list pane (about
half the terminal), so toggling the preview off with `alt-p` left the
freed horizontal space empty.
## How
The table is laid out once at full width. skim splits the screen and
renders the full-width rows into the left pane, clipping the overflow at
the boundary; toggling the preview off widens the list pane and the same
rows reveal their right-hand columns, with no reload and no re-layout.
Because the layout is computed once, the leading columns never move when
the preview toggles.
Three small changes in `src/commands/picker/mod.rs`:
1. `skim_list_width` is the full terminal width (minus skim's 2-column
cursor gutter), instead of half width in the Right layout.
2. `no_hscroll(true)` anchors the leading columns: a fuzzy match deep in
the search key can no longer shift them out of view.
3. An empty `ellipsis` makes the clip a clean left-anchored cut with no
`..`. (Empty is already the library default under `default-features =
false`; it is pinned explicitly because the clean clip is load-bearing.)
The Down (stacked) layout already used full width, so it is unchanged.
## Tradeoff
With the preview shown, the narrow left pane now shows the leftmost
slice of the full table rather than a layout optimized to fit the pane.
Because `Remote⇅` precedes `CI` in the column order, the full-width
layout can surface an often-empty `Remote⇅` at the pane edge and push
`CI`/`Age`/`Path` under the preview. Hiding the preview reveals all of
them in their natural positions. This is the inherent shape of the
chosen approach: the leading columns stay fixed, and the right edge is
whatever the full-width table places there. The picker PTY snapshots
capture this clipped-at-the-boundary state.
## Background
This started as a design exploration weighing three approaches: (1)
re-layout the table on toggle, (2) add an orientation toggle plus
re-layout, and (3) render at full width and let the preview cover the
right. skim splits the screen rather than overlaying, so option 3
reduces to clipping a full-width row at the split boundary. It is the
smallest change and the only one that never moves the leading columns,
so it was chosen. The design proposal that compared the options has been
removed (design docs are review-only by convention); its rationale now
lives in the code comments and this PR's history.
## Testing
- `cargo run -- hook pre-merge --yes`: all 4182 tests pass, clippy and
fmt clean.
- The three picker PTY snapshots (`switch_picker_abort_escape_list`,
`switch_picker_with_branches_list`,
`switch_picker_multiple_worktrees_list`) were regenerated; they confirm
the leading columns are unchanged and the boundary now reveals the next
column.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-24 21:32:54 -07:00
|
|
|
|
// Lay the table out at full terminal width regardless of the preview
|
|
|
|
|
|
// layout. With the preview shown (Right), skim splits the screen and renders
|
|
|
|
|
|
// this full-width row into the left pane, clipping the overflow at the
|
|
|
|
|
|
// boundary; `no_hscroll` plus an empty ellipsis (set on the builder below)
|
|
|
|
|
|
// make that a clean left-anchored cut. Toggling the preview off with alt-p
|
|
|
|
|
|
// widens skim's list pane to full width and the SAME rows reveal their
|
|
|
|
|
|
// right-hand columns — no reload, no re-layout, so no column ever moves.
|
|
|
|
|
|
// (The Down layout already used full width, so this is a no-op there.)
|
|
|
|
|
|
//
|
refactor(picker): read terminal size once from a canonical reader (#3219)
PR #3210 collapsed the picker's repeated `terminal_size()` reads into
one, but left the startup path reading dimensions from two different
sources: the layout sizing used `terminal_size::terminal_size()`
(stdout-first, `(80, 24)` fallback) while `skim_list_width` used
`display::terminal_width()` (stderr-first, `COLUMNS` fallback). On a
terminal where stdout and stderr point to different places, the preview
sizing and the list-column width could observe different widths.
This adds `terminal_dimensions() -> Option<(usize, Option<usize>)>` in
`styling/mod.rs` as the single canonical reader, with the existing probe
chain: stderr, then stdout, then `COLUMNS`. `terminal_width()` becomes
its width projection (`terminal_dimensions().map(|(w, _)| w)`), so its
external contract and all six other callers are untouched. The height is
`Option` because `COLUMNS` supplies a width with no height counterpart.
The picker reads the canonical source once as `term_dims` and derives
both the layout and `skim_list_width` from that one snapshot.
## The height carries the "real terminal detected" signal
The layout needs both width and height, so it trusts the snapshot only
when a real terminal supplied both — `Some((w, Some(h)))`. A width-only
`COLUMNS` reading (`Some((w, None))`), or no reading at all, falls back
to `80x24` for the layout, exactly as the old stdout-only read did.
`skim_list_width` needs only a width, so it still uses the `COLUMNS`
width via the same snapshot. This resolves the `COLUMNS`/height
asymmetry cleanly: the same read serves both callers, the
`Option<height>` is load-bearing, and the prior fallback behavior is
preserved.
## Behavior
For a real terminal — the picker requires a TTY, so the normal case —
both the layout and `skim_list_width` see the detected dimensions, so
the change is a no-op. `terminal_width()` is byte-identical for every
input, leaving its callers (`progress`, `help`, `styling::format`,
`commands::mod`, `list::layout`, `list::collect`) unaffected. The one
observable change is the rare split where stdout is not a TTY but stderr
is: the layout now tracks the real terminal via the stderr probe
(matching `skim_list_width` and what skim renders on) instead of falling
back to `80x24`.
`list::progressive_table` keeps its own stdout-only height probe: `wt
list` renders to stdout, so it must detect a real stdout TTY rather than
a stderr/`COLUMNS` fallback; folding it into the stderr-first reader
would change its behavior, so it stays separate.
This branch also merges `main`, reconciling with #3214 (the picker now
lays its table out at full width regardless of preview layout):
`skim_list_width` takes the full width from the same `term_dims`
snapshot, dropping the obsolete Right/Down split.
Both layout arms and the width derivation are covered by the existing
PTY picker tests (real terminal) and dry-run tests (`COLUMNS`-only
fallback); two unit tests lock the `terminal_width` ↔
`terminal_dimensions` delegation. The full local gate is green apart
from `test_switch_picker_alt_l_does_not_hscroll`, a picker PTY snapshot
that is environment-sensitive on macOS (it fails the same way on `main`
locally) and passes on CI — the change is output-neutral for it.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:29:17 -07:00
|
|
|
|
// The width comes from the same `term_dims` snapshot as the layout above;
|
|
|
|
|
|
// its fully-headless fallback is `usize::MAX` (vs. the layout's 80 width) to
|
|
|
|
|
|
// keep the math total when no width is known at all — the picker requires a
|
|
|
|
|
|
// TTY, so that only applies to the headless paths. Skim prefixes every line
|
|
|
|
|
|
// with a 2-column cursor gutter ("> "), so the full width loses 2.
|
|
|
|
|
|
let list_width_source = term_dims.map(|(w, _)| w).unwrap_or(usize::MAX);
|
|
|
|
|
|
let skim_list_width = list_width_source.saturating_sub(2);
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
// Estimate item count for the preview window spec (only the Down
|
feat(switch): scale picker list height to the terminal (#3205)
The interactive picker's Down layout (preview below the list) capped the
worktree list at a fixed 12 rows. On a tall terminal with many worktrees
that meant seeing only 12 — with all the surplus height going to a
near-empty preview pane — and the list never adapted to the space
available.
This replaces the `MAX_VISIBLE_ITEMS = 12` constant with
`max_visible_items(available)`, a balanced 50/50 split: the list may
claim up to half of skim's area (`available / 2`) and the preview keeps
the other half, so visible rows scale with terminal height. Integer
division truncates the list's half toward the preview — a deliberate
preview-favoring tie-break — and a `MIN_VISIBLE_ITEMS = 3` floor keeps
the list usable on a short terminal.
The tradeoff is at the common 80×24: it now shows ~6 rows / 11 preview
lines instead of the old 12 rows / 5-line (floor-crushed) preview — a
more balanced split. On a 50-row terminal it shows up to 18 rows; on a
120-row terminal up to 50.
### Navigating the diff
- `src/commands/picker/preview.rs` — the policy. `available_height()` is
the single home for skim's 90%-of-terminal conversion (both layout arms,
the estimate cap, and the half-page scroll all derive from it, retiring
a duplicated magic `45`). `max_visible_items()` is the cap;
`dimensions_for()` is a pure seam extracted from `preview_dimensions()`
so the split is unit-testable without a TTY.
- `src/commands/picker/mod.rs` — the `num_items_estimate` perf
short-circuit now gates on the same height-derived cap, and `half_page`
routes through `available_height()`.
- Right layout is untouched — it already used the full height and
ignores the item count.
### Testing
Six unit tests in `preview.rs` pin the full scenario grid (6 terminal
heights × 4 item counts), the cap table, the no-phantom-rows-when-empty
case, no-panic on degenerate terminals, and the saturation invariant
that keeps the estimate short-circuit sound.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:58:59 -07:00
|
|
|
|
// layout depends on it). The Down layout caps visible rows at
|
|
|
|
|
|
// `max_visible_items(available)`; every row past that cap is a no-op
|
|
|
|
|
|
// for the height computation, so we short-circuit once the estimate
|
|
|
|
|
|
// reaches it.
|
2026-04-15 13:31:05 -07:00
|
|
|
|
let num_items_estimate = {
|
refactor(picker): read terminal size once for layout sizing (#3210)
## What
Collapses the interactive picker's repeated `terminal_size()` reads into
a single read, threaded explicitly through the layout-sizing code.
## Why
PR #3205 made the picker's Down-layout list height adapt to the
terminal, but left the startup path reading the terminal size 3–4 times
per launch — once in `auto_detect` (layout), once for the
`num_items_estimate` cap, once each inside `to_preview_window_spec` and
`preview_dimensions`, once for the speculative pre-compute, and once for
`half_page`. `to_preview_window_spec` re-read the terminal and
recomputed the Down spec internally, so the Down preview dimensions were
computed twice. Beyond the redundant syscalls, the estimate cap and the
actual layout could observe different terminal sizes if the window was
resized mid-startup — a benign but real race.
## How
`handle_picker` now reads `terminal_size::terminal_size()` once and
threads `(term_width, term_height)` into every sizing site: layout
detection (`PreviewLayout::for_dimensions`), the visible-row cap
(`max_visible_items(available_height(term_height))`), the preview
dimensions (`dimensions_for`), the speculative pre-compute, and the
half-page scroll. `dimensions_for` — already pure and unit-tested — is
the single entry; `spec_for` formats the skim preview-window spec from
the already-computed dims rather than recomputing them.
This retires three terminal-reading methods on `PreviewLayout`:
`auto_detect` (folded into the single read + `for_dimensions`),
`preview_dimensions` (the live-terminal reader), and
`to_preview_window_spec` (which re-read and recomputed). `preview.rs` no
longer reads the terminal at all — the read lives solely in
`handle_picker`. `crate::display::terminal_width()` (a separate
stderr-first width probe for the skim list column) is left as-is; it
isn't part of the layout-sizing path.
## Behavior
No user-facing change. Fallbacks are preserved at every site — the
single read falls back to `(80, 24)`, matching the prior per-call
fallbacks, and `half_page` on that fallback still evaluates to `10`
(`(available_height(24) / 2).max(5)` = `(21 / 2).max(5)` = `10`),
identical to the old `.unwrap_or(10)`. The pre-existing `dimensions_for`
scenario/edge tests pass unchanged; the one spec-formatting test was
retargeted at `spec_for` with exact-string assertions (strictly
stronger), and a redundant duplicate of it in `mod.rs` was removed.
`cargo run -- hook pre-merge --yes` is green (4181 tests, clippy, fmt).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:29:21 -07:00
|
|
|
|
let cap = preview::max_visible_items(preview::available_height(term_height));
|
2026-04-15 13:31:05 -07:00
|
|
|
|
let mut estimate = repo.list_worktrees().map(|w| w.len()).unwrap_or(cap);
|
|
|
|
|
|
if estimate < cap && show_branches {
|
|
|
|
|
|
// Local branches are a superset of worktree branches (each
|
|
|
|
|
|
// linked worktree normally has one), so take the max rather
|
|
|
|
|
|
// than summing.
|
refactor(repo): consolidate branch-enumeration scans into a single inventory (#2368)
## Summary
`src/git/repository/branches.rs` had five overlapping `for-each-ref`
accessors, each with a subtly different format string: `all_branches`,
`list_local_branches`, `list_remote_branches`, `list_tracked_upstreams`,
`fetch_all_upstreams`. On `wt list --branches --remotes` at least three
of them fired in one invocation. `Branch` also exposed both `upstream()`
(bulk scan, lazy) and `upstream_single()` (one-off) — two paths to the
same answer.
Replaces the whole surface with two scans cached on `RepoCache`:
- `refs/heads/` → `LocalBranchInventory` (`Vec<LocalBranch>` + name →
index map for O(1) lookups). Carries upstream info + SHA so no follow-up
scan is needed.
- `refs/remotes/` → `Vec<RemoteBranch>`.
Public accessors: `Repository::local_branches() -> &[LocalBranch]`,
`Repository::remote_branches() -> &[RemoteBranch]`. `Branch::upstream()`
is now a single canonical lookup through the inventory;
`upstream_single` is gone.
## Navigating the diff
- `src/git/repository/branches.rs` — new inventory types, scan
functions, and the surviving public accessors (`all_branches`,
`available_branches`, `branches_for_completion`).
- `src/git/repository/branch.rs` — `Branch::upstream` now reads from the
inventory via `Repository::local_branch(name)`.
- `src/git/mod.rs` — new `LocalBranch` / `RemoteBranch` public types.
- `src/git/repository/mod.rs` — two new `OnceCell`s on `RepoCache`
(replaces the old `upstreams` cell).
- `src/commands/list/collect/mod.rs` — parallel phase primes the
inventory; later consumers read from the cache. The old
`list_untracked_remote_branches` helper is inlined at its sole caller.
- `src/commands/picker/mod.rs`, `src/commands/worktree/switch.rs`,
`src/commands/command_executor.rs`, `src/git/repository/integration.rs`
— callers updated to the canonical `upstream()` / inventory accessors.
## Results
- Subprocess count on `wt list --branches --remotes` drops from ≥3
`for-each-ref` scans of `refs/heads/` + 1 of `refs/remotes/` down to
exactly 2 (one per ref namespace).
- `skeleton/warm/8` benchmark: ~89ms on main → ~27ms here, well under
the 60ms target.
- Format string uses `%00` as the field separator so NUL-containing args
never hit `Command::arg` (Rust rejects them before exec).
## Test plan
- [x] \`cargo run -- hook pre-merge --yes\` (3325 tests pass, all lints
clean)
- [x] Subprocess count verified by `RUST_LOG=worktrunk=debug wt list
--branches --remotes` — exactly 2 inventory `for-each-ref` calls (plus
the unrelated ahead-behind batch that already existed).
- [x] Skeleton benchmark checked against main.
> _This was written by Claude Code on behalf of @max-sixty_
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-21 18:22:42 -07:00
|
|
|
|
let local = repo.local_branches().map(|b| b.len()).unwrap_or(cap);
|
2026-04-15 13:31:05 -07:00
|
|
|
|
estimate = estimate.max(local);
|
|
|
|
|
|
}
|
|
|
|
|
|
if estimate < cap && show_remotes {
|
refactor(repo): consolidate branch-enumeration scans into a single inventory (#2368)
## Summary
`src/git/repository/branches.rs` had five overlapping `for-each-ref`
accessors, each with a subtly different format string: `all_branches`,
`list_local_branches`, `list_remote_branches`, `list_tracked_upstreams`,
`fetch_all_upstreams`. On `wt list --branches --remotes` at least three
of them fired in one invocation. `Branch` also exposed both `upstream()`
(bulk scan, lazy) and `upstream_single()` (one-off) — two paths to the
same answer.
Replaces the whole surface with two scans cached on `RepoCache`:
- `refs/heads/` → `LocalBranchInventory` (`Vec<LocalBranch>` + name →
index map for O(1) lookups). Carries upstream info + SHA so no follow-up
scan is needed.
- `refs/remotes/` → `Vec<RemoteBranch>`.
Public accessors: `Repository::local_branches() -> &[LocalBranch]`,
`Repository::remote_branches() -> &[RemoteBranch]`. `Branch::upstream()`
is now a single canonical lookup through the inventory;
`upstream_single` is gone.
## Navigating the diff
- `src/git/repository/branches.rs` — new inventory types, scan
functions, and the surviving public accessors (`all_branches`,
`available_branches`, `branches_for_completion`).
- `src/git/repository/branch.rs` — `Branch::upstream` now reads from the
inventory via `Repository::local_branch(name)`.
- `src/git/mod.rs` — new `LocalBranch` / `RemoteBranch` public types.
- `src/git/repository/mod.rs` — two new `OnceCell`s on `RepoCache`
(replaces the old `upstreams` cell).
- `src/commands/list/collect/mod.rs` — parallel phase primes the
inventory; later consumers read from the cache. The old
`list_untracked_remote_branches` helper is inlined at its sole caller.
- `src/commands/picker/mod.rs`, `src/commands/worktree/switch.rs`,
`src/commands/command_executor.rs`, `src/git/repository/integration.rs`
— callers updated to the canonical `upstream()` / inventory accessors.
## Results
- Subprocess count on `wt list --branches --remotes` drops from ≥3
`for-each-ref` scans of `refs/heads/` + 1 of `refs/remotes/` down to
exactly 2 (one per ref namespace).
- `skeleton/warm/8` benchmark: ~89ms on main → ~27ms here, well under
the 60ms target.
- Format string uses `%00` as the field separator so NUL-containing args
never hit `Command::arg` (Rust rejects them before exec).
## Test plan
- [x] \`cargo run -- hook pre-merge --yes\` (3325 tests pass, all lints
clean)
- [x] Subprocess count verified by `RUST_LOG=worktrunk=debug wt list
--branches --remotes` — exactly 2 inventory `for-each-ref` calls (plus
the unrelated ahead-behind batch that already existed).
- [x] Skeleton benchmark checked against main.
> _This was written by Claude Code on behalf of @max-sixty_
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-21 18:22:42 -07:00
|
|
|
|
let remotes = repo.remote_branches().map(|b| b.len()).unwrap_or(0);
|
2026-04-15 13:31:05 -07:00
|
|
|
|
estimate = estimate.saturating_add(remotes);
|
|
|
|
|
|
}
|
|
|
|
|
|
estimate
|
|
|
|
|
|
};
|
refactor(trace): unify in-process trace-trigger surface (#2554)
Three small consolidations in the trace-trigger surface, following up on
#2539.
## What this changes
**1. `Cmd::stream()` emits `[wt-trace] cmd=...` natively.** Previously
only `Cmd::run()` and `Cmd::pipe_into()` emitted per-subprocess records
— `stream()` was a hole, which #2539 patched with a
`Span::new(\"execute_shell_command\")` wrapper at the foreground
hook/alias call site. The two surrogates aren't equivalent: spans render
under `cat: \"wt\"`, subprocess records under `cat:
\"git\"`/`\"network\"`, and spans don't carry the `ok` flag, so
hook/alias child status was invisible in chrome traces. Stream now emits
a record at every exit point (spawn fail, stdin write, wait fail,
signal-derived exit, SIGPIPE-as-success, non-zero status, success) via a
small `WtTraceLog` helper that mirrors `ExternalCommandLog`'s shape.
**2. Drops the `Span::new(\"execute_shell_command\")` workaround** in
`commands/command_executor.rs`. With `Cmd::stream()` emitting natively,
the wrapper is redundant — foreground hook/alias step time is now
captured by the canonical subprocess record (cat=`git`/`network`/none)
instead of a generic span (cat=`wt`).
**3. Re-exports `trace::instant` from `trace::mod`** alongside `Span`.
Deletes the `shell_exec::trace_instant` shim (a one-line re-export of
`trace::emit::instant`) and migrates all 14 callers in
`commands/picker/mod.rs` and `commands/list/collect/mod.rs` to
`worktrunk::trace::instant`. Symmetric public API: `trace::Span` for
scopes, `trace::instant` for milestones — neither lives under
`shell_exec` anymore, since neither has anything to do with shell
execution.
## Verification
Smoke-tested with `RUST_LOG=debug wt <alias>`: `cmd=\"echo
hello-from-stream\" ok=true` and `cmd=\"exit 7\" ok=false` both fire
correctly. `Span(\"execute_shell_command\")` no longer appears in the
trace. Full pre-merge hook (3430 tests + clippy + lints) passes locally.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:14:19 -07:00
|
|
|
|
worktrunk::trace::instant("Picker estimate computed");
|
refactor(picker): read terminal size once for layout sizing (#3210)
## What
Collapses the interactive picker's repeated `terminal_size()` reads into
a single read, threaded explicitly through the layout-sizing code.
## Why
PR #3205 made the picker's Down-layout list height adapt to the
terminal, but left the startup path reading the terminal size 3–4 times
per launch — once in `auto_detect` (layout), once for the
`num_items_estimate` cap, once each inside `to_preview_window_spec` and
`preview_dimensions`, once for the speculative pre-compute, and once for
`half_page`. `to_preview_window_spec` re-read the terminal and
recomputed the Down spec internally, so the Down preview dimensions were
computed twice. Beyond the redundant syscalls, the estimate cap and the
actual layout could observe different terminal sizes if the window was
resized mid-startup — a benign but real race.
## How
`handle_picker` now reads `terminal_size::terminal_size()` once and
threads `(term_width, term_height)` into every sizing site: layout
detection (`PreviewLayout::for_dimensions`), the visible-row cap
(`max_visible_items(available_height(term_height))`), the preview
dimensions (`dimensions_for`), the speculative pre-compute, and the
half-page scroll. `dimensions_for` — already pure and unit-tested — is
the single entry; `spec_for` formats the skim preview-window spec from
the already-computed dims rather than recomputing them.
This retires three terminal-reading methods on `PreviewLayout`:
`auto_detect` (folded into the single read + `for_dimensions`),
`preview_dimensions` (the live-terminal reader), and
`to_preview_window_spec` (which re-read and recomputed). `preview.rs` no
longer reads the terminal at all — the read lives solely in
`handle_picker`. `crate::display::terminal_width()` (a separate
stderr-first width probe for the skim list column) is left as-is; it
isn't part of the layout-sizing path.
## Behavior
No user-facing change. Fallbacks are preserved at every site — the
single read falls back to `(80, 24)`, matching the prior per-call
fallbacks, and `half_page` on that fallback still evaluates to `10`
(`(available_height(24) / 2).max(5)` = `(21 / 2).max(5)` = `10`),
identical to the old `.unwrap_or(10)`. The pre-existing `dimensions_for`
scenario/edge tests pass unchanged; the one spec-formatting test was
retargeted at `spec_for` with exact-string assertions (strictly
stronger), and a redundant duplicate of it in `mod.rs` was removed.
`cargo run -- hook pre-merge --yes` is green (4181 tests, clippy, fmt).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:29:21 -07:00
|
|
|
|
// Compute the dimensions once; the skim preview-window spec is formatted
|
|
|
|
|
|
// from them rather than recomputed.
|
|
|
|
|
|
let preview_dims =
|
|
|
|
|
|
state
|
|
|
|
|
|
.initial_layout
|
|
|
|
|
|
.dimensions_for(term_width, term_height, num_items_estimate);
|
|
|
|
|
|
let preview_window_spec = state.initial_layout.spec_for(preview_dims);
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
|
|
|
|
|
|
// Summary hint: when summaries are disabled, prime the Summary cache
|
|
|
|
|
|
// with config guidance instead of showing a perpetual "Generating…"
|
|
|
|
|
|
// placeholder.
|
|
|
|
|
|
let (llm_command, summary_hint) =
|
|
|
|
|
|
if config.list.summary() && config.commit_generation.is_configured() {
|
|
|
|
|
|
(config.commit_generation.command.clone(), None)
|
|
|
|
|
|
} else {
|
2026-06-28 16:00:57 -07:00
|
|
|
|
// Point at the config file wt actually loads from, not a hardcoded
|
|
|
|
|
|
// default (resolution + fallback live in `config_path_for_display`).
|
|
|
|
|
|
let config_path = worktrunk::config::config_path_for_display();
|
fix(picker): show the resolved config path in disabled-summary hints (#3290)
When LLM summaries are disabled, the `wt switch` picker's Summary tab is
seeded with one of two hints — "Summaries not configured" when there's
no `[commit.generation]` command, or "Summaries off" when `[list]
summary = false`. Both previously told the user to edit a hardcoded
`~/.config/worktrunk/config.toml`, which is wrong whenever wt loads
config from elsewhere: a `--config` flag, the `WORKTRUNK_CONFIG_PATH`
env var, or a non-default `$XDG_CONFIG_HOME`. A user following the hint
would edit a file wt never reads.
Both hints now show the path wt actually loads from, resolved once at
picker startup via `worktrunk::config::config_path()` (priority:
`--config` → `WORKTRUNK_CONFIG_PATH` → platform default) and rendered
through `format_path_for_display` so it shows `~/…`. When no location
can be determined, it falls back to the canonical literal. This mirrors
the resolved-path idiom already in `src/commands/worktree/resolve.rs`.
The hints are also reshaped into the gutter house style — a bold
H4-subject first line and a fenced ` ```toml ` config block — structured
so the PTY snapshot is platform-stable. `render_summary` word-wraps
prose to the preview width, which is one column narrower under Windows'
PTY, so any prose body long enough to wrap breaks on a different word
there and a single cross-platform snapshot can't match. To avoid that,
the only prose is a short lead line that can't wrap, the resolved path
sits alone on its own line (one unbreakable token, so there's no wrap
boundary to shift), and the config block is never wrapped (code blocks
aren't). The H4 subject is never wrapped either.
## Tests
`test_switch_picker_preview_panel_summary_disabled` is added to cover
the "configured but `summary = false`" arm, which had no test before
(the existing summary test only exercises the not-configured arm) —
closing a `codecov/patch` gap. Both arms' PTY snapshots render the
resolved path; under the tests' `WORKTRUNK_CONFIG_PATH` isolation that
path is a per-run temp file, which the existing snapshot filter redacts
to a stable `[TEST_CONFIG]` placeholder, so the snapshots stay
host-independent and `test_no_host_specific_paths_in_snapshots` is
satisfied.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:21:33 -07:00
|
|
|
|
// Keep every prose line short and put the resolved path on its own
|
|
|
|
|
|
// line. `render_summary` word-wraps prose to the preview width, and
|
|
|
|
|
|
// that width is a column narrower under Windows' PTY — so a sentence
|
|
|
|
|
|
// long enough to wrap lands its break on a different word there, and
|
|
|
|
|
|
// the single cross-platform snapshot can't match. A short lead line,
|
|
|
|
|
|
// the path alone (one unbreakable token, no wrap boundary to shift),
|
|
|
|
|
|
// and the fenced config block (code blocks are never wrapped) all
|
|
|
|
|
|
// render identically on every platform. The first line stays the bold
|
|
|
|
|
|
// H4 subject, which `render_summary` promotes and never wraps.
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
let hint = if !config.commit_generation.is_configured() {
|
fix(picker): show the resolved config path in disabled-summary hints (#3290)
When LLM summaries are disabled, the `wt switch` picker's Summary tab is
seeded with one of two hints — "Summaries not configured" when there's
no `[commit.generation]` command, or "Summaries off" when `[list]
summary = false`. Both previously told the user to edit a hardcoded
`~/.config/worktrunk/config.toml`, which is wrong whenever wt loads
config from elsewhere: a `--config` flag, the `WORKTRUNK_CONFIG_PATH`
env var, or a non-default `$XDG_CONFIG_HOME`. A user following the hint
would edit a file wt never reads.
Both hints now show the path wt actually loads from, resolved once at
picker startup via `worktrunk::config::config_path()` (priority:
`--config` → `WORKTRUNK_CONFIG_PATH` → platform default) and rendered
through `format_path_for_display` so it shows `~/…`. When no location
can be determined, it falls back to the canonical literal. This mirrors
the resolved-path idiom already in `src/commands/worktree/resolve.rs`.
The hints are also reshaped into the gutter house style — a bold
H4-subject first line and a fenced ` ```toml ` config block — structured
so the PTY snapshot is platform-stable. `render_summary` word-wraps
prose to the preview width, which is one column narrower under Windows'
PTY, so any prose body long enough to wrap breaks on a different word
there and a single cross-platform snapshot can't match. To avoid that,
the only prose is a short lead line that can't wrap, the resolved path
sits alone on its own line (one unbreakable token, so there's no wrap
boundary to shift), and the config block is never wrapped (code blocks
aren't). The H4 subject is never wrapped either.
## Tests
`test_switch_picker_preview_panel_summary_disabled` is added to cover
the "configured but `summary = false`" arm, which had no test before
(the existing summary test only exercises the not-configured arm) —
closing a `codecov/patch` gap. Both arms' PTY snapshots render the
resolved path; under the tests' `WORKTRUNK_CONFIG_PATH` isolation that
path is a per-run temp file, which the existing snapshot filter redacts
to a stable `[TEST_CONFIG]` placeholder, so the snapshots stay
host-independent and `test_no_host_specific_paths_in_snapshots` is
satisfied.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:21:33 -07:00
|
|
|
|
format!(
|
|
|
|
|
|
r#"Summaries not configured
|
|
|
|
|
|
|
|
|
|
|
|
Add a [commit.generation] command in:
|
|
|
|
|
|
{config_path}
|
|
|
|
|
|
|
|
|
|
|
|
```toml
|
|
|
|
|
|
[commit.generation]
|
|
|
|
|
|
command = "llm -m haiku"
|
|
|
|
|
|
|
|
|
|
|
|
[list]
|
|
|
|
|
|
summary = true
|
|
|
|
|
|
```
|
|
|
|
|
|
"#
|
|
|
|
|
|
)
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
} else {
|
fix(picker): show the resolved config path in disabled-summary hints (#3290)
When LLM summaries are disabled, the `wt switch` picker's Summary tab is
seeded with one of two hints — "Summaries not configured" when there's
no `[commit.generation]` command, or "Summaries off" when `[list]
summary = false`. Both previously told the user to edit a hardcoded
`~/.config/worktrunk/config.toml`, which is wrong whenever wt loads
config from elsewhere: a `--config` flag, the `WORKTRUNK_CONFIG_PATH`
env var, or a non-default `$XDG_CONFIG_HOME`. A user following the hint
would edit a file wt never reads.
Both hints now show the path wt actually loads from, resolved once at
picker startup via `worktrunk::config::config_path()` (priority:
`--config` → `WORKTRUNK_CONFIG_PATH` → platform default) and rendered
through `format_path_for_display` so it shows `~/…`. When no location
can be determined, it falls back to the canonical literal. This mirrors
the resolved-path idiom already in `src/commands/worktree/resolve.rs`.
The hints are also reshaped into the gutter house style — a bold
H4-subject first line and a fenced ` ```toml ` config block — structured
so the PTY snapshot is platform-stable. `render_summary` word-wraps
prose to the preview width, which is one column narrower under Windows'
PTY, so any prose body long enough to wrap breaks on a different word
there and a single cross-platform snapshot can't match. To avoid that,
the only prose is a short lead line that can't wrap, the resolved path
sits alone on its own line (one unbreakable token, so there's no wrap
boundary to shift), and the config block is never wrapped (code blocks
aren't). The H4 subject is never wrapped either.
## Tests
`test_switch_picker_preview_panel_summary_disabled` is added to cover
the "configured but `summary = false`" arm, which had no test before
(the existing summary test only exercises the not-configured arm) —
closing a `codecov/patch` gap. Both arms' PTY snapshots render the
resolved path; under the tests' `WORKTRUNK_CONFIG_PATH` isolation that
path is a per-run temp file, which the existing snapshot filter redacts
to a stable `[TEST_CONFIG]` placeholder, so the snapshots stay
host-independent and `test_no_host_specific_paths_in_snapshots` is
satisfied.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:21:33 -07:00
|
|
|
|
format!(
|
|
|
|
|
|
r#"Summaries off
|
|
|
|
|
|
|
|
|
|
|
|
Enable summaries in:
|
|
|
|
|
|
{config_path}
|
|
|
|
|
|
|
|
|
|
|
|
```toml
|
|
|
|
|
|
[list]
|
|
|
|
|
|
summary = true
|
|
|
|
|
|
```
|
|
|
|
|
|
"#
|
|
|
|
|
|
)
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
};
|
fix(picker): show the resolved config path in disabled-summary hints (#3290)
When LLM summaries are disabled, the `wt switch` picker's Summary tab is
seeded with one of two hints — "Summaries not configured" when there's
no `[commit.generation]` command, or "Summaries off" when `[list]
summary = false`. Both previously told the user to edit a hardcoded
`~/.config/worktrunk/config.toml`, which is wrong whenever wt loads
config from elsewhere: a `--config` flag, the `WORKTRUNK_CONFIG_PATH`
env var, or a non-default `$XDG_CONFIG_HOME`. A user following the hint
would edit a file wt never reads.
Both hints now show the path wt actually loads from, resolved once at
picker startup via `worktrunk::config::config_path()` (priority:
`--config` → `WORKTRUNK_CONFIG_PATH` → platform default) and rendered
through `format_path_for_display` so it shows `~/…`. When no location
can be determined, it falls back to the canonical literal. This mirrors
the resolved-path idiom already in `src/commands/worktree/resolve.rs`.
The hints are also reshaped into the gutter house style — a bold
H4-subject first line and a fenced ` ```toml ` config block — structured
so the PTY snapshot is platform-stable. `render_summary` word-wraps
prose to the preview width, which is one column narrower under Windows'
PTY, so any prose body long enough to wrap breaks on a different word
there and a single cross-platform snapshot can't match. To avoid that,
the only prose is a short lead line that can't wrap, the resolved path
sits alone on its own line (one unbreakable token, so there's no wrap
boundary to shift), and the config block is never wrapped (code blocks
aren't). The H4 subject is never wrapped either.
## Tests
`test_switch_picker_preview_panel_summary_disabled` is added to cover
the "configured but `summary = false`" arm, which had no test before
(the existing summary test only exercises the not-configured arm) —
closing a `codecov/patch` gap. Both arms' PTY snapshots render the
resolved path; under the tests' `WORKTRUNK_CONFIG_PATH` isolation that
path is a per-run temp file, which the existing snapshot filter redacts
to a stable `[TEST_CONFIG]` placeholder, so the snapshots stay
host-independent and `test_no_host_specific_paths_in_snapshots` is
satisfied.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:21:33 -07:00
|
|
|
|
(None, Some(hint))
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
};
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
fix(picker): keep --prs rows visible after an alt-x removal (#3275)
## The bug
In the `wt switch --prs` interactive picker, removing a worktree row
with `alt-x` made the streamed PR/MR rows vanish from the list until the
user pressed `alt-r` to refresh. The worktree/branch rows survived; only
the `--prs` rows disappeared.
## Root cause
The `alt-x` removal rework (`d66bc6af5`) replaced the old `reload(remove
{})` with a synchronous `resync_pool` that rebuilds skim's item pool
from the picker's `shared_items` Vec. But `shared_items` only ever held
the skeleton (worktree/branch) rows: `on_skeleton` populates it, while
the `--prs` thread streams its rows straight to skim's item channel
(`prs::fetch_and_stream` → `tx.send`) and never recorded them in
`shared_items`. So when `resync_pool` rebuilt the pool from
`shared_items`, the PR rows were dropped. They only reappeared when
`alt-r` re-ran the whole collect + `--prs` pipeline. (The old `reload`
path had the same blind spot; it matters more now that `alt-x` is the
sole removal path.)
## The fix
The `--prs` thread now appends its PR/MR rows into `shared_items` as
well as streaming them to skim — the same way it already extends
`shortcut_table`. With `shared_items` holding the full row set (header +
worktree/branch + PR/MR rows), `resync_pool` preserves the PR rows on an
`alt-x` removal for free.
The append is guarded by a per-spawn epoch counter
(`PipelineFactory::prs_epoch`, handed to each spawn's `--prs` thread via
`PrsShared`). An `alt-r` refresh spawns a fresh `--prs` thread while the
prior spawn's forge call may still be in flight; without the guard, that
stale call (whose skim channel is already dropped) would re-add
now-duplicate rows to the list a newer spawn rebuilt. The epoch is read
under the `shared_items` lock so the check pairs with the next spawn's
`on_skeleton` overwrite, which holds the same lock. The append is
ordered before `tx.send` so the rows reach `shared_items` no later than
they reach skim's pool (favoring a sub-microsecond transient-duplicate
window over re-dropping rows, were the order reversed).
The no-flash cursor behavior from `d66bc6af5` is unchanged: the rebuilt
list is just longer, so the cursor holds its index and the row that
slides into the removed slot lands under it.
## Testing
New PTY regression test
`test_switch_picker_prs_rows_survive_alt_x_removal` drives the drop path
(a clean, integrated worktree) in `--prs` mode and asserts the `#42` PR
row survives the removal. The mock answers `gh pr list --state` (the
`--prs` fetch) with PR #42 but `gh pr list --head <branch>` (the
per-worktree CI fetch) with an empty list, so `#42` appears only as a
`--prs` row, never folded into a worktree row's CI cell. Confirmed: the
test fails without the fix and passes with it. Full pre-merge gate green
(4257 tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:41:48 -07:00
|
|
|
|
// The picker's full row list — header, worktree/branch rows, and (in `--prs`
|
|
|
|
|
|
// mode) PR/MR rows. `on_skeleton` fills it with the header + worktree/branch
|
|
|
|
|
|
// rows and the `--prs` thread appends its PR/MR rows; an `alt-x` removal
|
|
|
|
|
|
// mutates it (`AltXRemover`) and rebuilds skim's pool from it (`resync_pool`).
|
|
|
|
|
|
// Starts empty — those writers run only after skim is displaying rows.
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
let shared_items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>> = Arc::new(Mutex::new(Vec::new()));
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// `alt-y` / `alt-o` lookup table (token → branch + URL). The collect handler
|
|
|
|
|
|
// fills it with worktree/branch rows and the `--prs` thread extends it; the
|
|
|
|
|
|
// shortcut keybinding callbacks read it. See `ShortcutTable`.
|
|
|
|
|
|
let shortcut_table: ShortcutTable = Arc::new(Mutex::new(std::collections::HashMap::new()));
|
|
|
|
|
|
|
|
|
|
|
|
// Approvals snapshot for the session: alt-x removals consult it read-only
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
// to filter the hook plan; see `approved_removal_plan`.
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
let approvals = Arc::new(Approvals::load().context("Failed to load approvals")?);
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// Shared between the bg-thread collect handler and a failed alt-x removal
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
// (both push warnings while skim owns the terminal) and the main thread
|
|
|
|
|
|
// (which drains them after `Skim::run_with` returns and stderr is safe
|
|
|
|
|
|
// again).
|
|
|
|
|
|
let stashed_warnings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// The collect pipeline, captured so the initial spawn below and every alt-r
|
|
|
|
|
|
// refresh build it the same way. See `PipelineFactory`.
|
|
|
|
|
|
let factory = Rc::new(PipelineFactory {
|
|
|
|
|
|
repo: repo.clone(),
|
|
|
|
|
|
render_tx: Arc::clone(&render_tx),
|
|
|
|
|
|
shared_items: Arc::clone(&shared_items),
|
|
|
|
|
|
shortcut_table: Arc::clone(&shortcut_table),
|
|
|
|
|
|
preview_cache: Arc::clone(&preview_cache),
|
|
|
|
|
|
orchestrator: Arc::clone(&orchestrator),
|
|
|
|
|
|
stashed_warnings: Arc::clone(&stashed_warnings),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// Full-layout handoff: the handler fills it in `provide_layout`, the
|
|
|
|
|
|
// collector reads it to render the `alt-x` `/ branch` row on this grid.
|
|
|
|
|
|
layout_slot: Arc::new(Mutex::new(None)),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
header_flash: Arc::new(items::HeaderFlash::default()),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
preview_dims,
|
|
|
|
|
|
skim_list_width,
|
|
|
|
|
|
command_timeout,
|
|
|
|
|
|
llm_command,
|
|
|
|
|
|
summary_hint,
|
|
|
|
|
|
show_branches,
|
|
|
|
|
|
show_remotes,
|
|
|
|
|
|
show_prs,
|
|
|
|
|
|
is_preview_bench,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// skim's pull-based reader side: only `alt-r` (`reload(refresh)`) reaches this
|
|
|
|
|
|
// now — `alt-x` removal runs synchronously through the `AltXRemover` below.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
let collector = PickerCollector {
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
items: Arc::clone(&shared_items),
|
|
|
|
|
|
factory: Rc::clone(&factory),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// The `alt-x` removal handler. Holds only `Send` state (every field an `Arc`,
|
|
|
|
|
|
// or the `Send` `Repository`) so it can move into the keybinding's `Send`
|
|
|
|
|
|
// callback — it can't carry the collector's `Rc<PipelineFactory>`, so it owns
|
|
|
|
|
|
// the morph/keep shared slots directly. See `AltXRemover` and
|
|
|
|
|
|
// `install_remove_keybinding`.
|
|
|
|
|
|
let alt_x_remover = AltXRemover {
|
2026-03-23 12:18:42 -07:00
|
|
|
|
items: Arc::clone(&shared_items),
|
|
|
|
|
|
repo: repo.clone(),
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
approvals,
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
render_tx: Arc::clone(&render_tx),
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
stashed_warnings: Arc::clone(&stashed_warnings),
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
shortcut_table: Arc::clone(&shortcut_table),
|
|
|
|
|
|
layout_slot: Arc::clone(&factory.layout_slot),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
header_flash: Arc::clone(&factory.header_flash),
|
2026-03-23 12:18:42 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
feat(switch): scale picker list height to the terminal (#3205)
The interactive picker's Down layout (preview below the list) capped the
worktree list at a fixed 12 rows. On a tall terminal with many worktrees
that meant seeing only 12 — with all the surplus height going to a
near-empty preview pane — and the list never adapted to the space
available.
This replaces the `MAX_VISIBLE_ITEMS = 12` constant with
`max_visible_items(available)`, a balanced 50/50 split: the list may
claim up to half of skim's area (`available / 2`) and the preview keeps
the other half, so visible rows scale with terminal height. Integer
division truncates the list's half toward the preview — a deliberate
preview-favoring tie-break — and a `MIN_VISIBLE_ITEMS = 3` floor keeps
the list usable on a short terminal.
The tradeoff is at the common 80×24: it now shows ~6 rows / 11 preview
lines instead of the old 12 rows / 5-line (floor-crushed) preview — a
more balanced split. On a 50-row terminal it shows up to 18 rows; on a
120-row terminal up to 50.
### Navigating the diff
- `src/commands/picker/preview.rs` — the policy. `available_height()` is
the single home for skim's 90%-of-terminal conversion (both layout arms,
the estimate cap, and the half-page scroll all derive from it, retiring
a duplicated magic `45`). `max_visible_items()` is the cap;
`dimensions_for()` is a pure seam extracted from `preview_dimensions()`
so the split is unit-testable without a TTY.
- `src/commands/picker/mod.rs` — the `num_items_estimate` perf
short-circuit now gates on the same height-derived cap, and `half_page`
routes through `available_height()`.
- Right layout is untouched — it already used the full height and
ignores the item count.
### Testing
Six unit tests in `preview.rs` pin the full scenario grid (6 terminal
heights × 4 item counts), the cap table, the no-phantom-rows-when-empty
case, no-panic on degenerate terminals, and the saturation invariant
that keeps the estimate short-circuit sound.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:58:59 -07:00
|
|
|
|
// Half-page preview scroll: half of skim's usable height.
|
refactor(picker): read terminal size once for layout sizing (#3210)
## What
Collapses the interactive picker's repeated `terminal_size()` reads into
a single read, threaded explicitly through the layout-sizing code.
## Why
PR #3205 made the picker's Down-layout list height adapt to the
terminal, but left the startup path reading the terminal size 3–4 times
per launch — once in `auto_detect` (layout), once for the
`num_items_estimate` cap, once each inside `to_preview_window_spec` and
`preview_dimensions`, once for the speculative pre-compute, and once for
`half_page`. `to_preview_window_spec` re-read the terminal and
recomputed the Down spec internally, so the Down preview dimensions were
computed twice. Beyond the redundant syscalls, the estimate cap and the
actual layout could observe different terminal sizes if the window was
resized mid-startup — a benign but real race.
## How
`handle_picker` now reads `terminal_size::terminal_size()` once and
threads `(term_width, term_height)` into every sizing site: layout
detection (`PreviewLayout::for_dimensions`), the visible-row cap
(`max_visible_items(available_height(term_height))`), the preview
dimensions (`dimensions_for`), the speculative pre-compute, and the
half-page scroll. `dimensions_for` — already pure and unit-tested — is
the single entry; `spec_for` formats the skim preview-window spec from
the already-computed dims rather than recomputing them.
This retires three terminal-reading methods on `PreviewLayout`:
`auto_detect` (folded into the single read + `for_dimensions`),
`preview_dimensions` (the live-terminal reader), and
`to_preview_window_spec` (which re-read and recomputed). `preview.rs` no
longer reads the terminal at all — the read lives solely in
`handle_picker`. `crate::display::terminal_width()` (a separate
stderr-first width probe for the skim list column) is left as-is; it
isn't part of the layout-sizing path.
## Behavior
No user-facing change. Fallbacks are preserved at every site — the
single read falls back to `(80, 24)`, matching the prior per-call
fallbacks, and `half_page` on that fallback still evaluates to `10`
(`(available_height(24) / 2).max(5)` = `(21 / 2).max(5)` = `10`),
identical to the old `.unwrap_or(10)`. The pre-existing `dimensions_for`
scenario/edge tests pass unchanged; the one spec-formatting test was
retargeted at `spec_for` with exact-string assertions (strictly
stronger), and a redundant duplicate of it in `mod.rs` was removed.
`cargo run -- hook pre-merge --yes` is green (4181 tests, clippy, fmt).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:29:21 -07:00
|
|
|
|
let half_page = (preview::available_height(term_height) / 2).max(5);
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
// Configure skim options with Rust-based preview and mode switching keybindings
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
let mut options = SkimOptionsBuilder::default()
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
.height("90%".to_string())
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
.reverse(true)
|
fix(picker): keep the empty-query list in collect order (#3301)
The `wt switch` picker's default view (no query typed) reordered rows by
where each name's last `/` falls, instead of showing them in collect
order. Any slash-bearing row — a `feature/…` PR head branch, a `perf/…`
worktree branch, or a `/`-gutter local branch — sank toward the bottom
and intermixed with rows of other kinds. A worktree like
`perf/list-prune-…` would appear stranded among the `#` PR rows rather
than up with the other worktrees.
**Cause.** The picker set a custom skim tiebreak `[Score, PathName,
Begin, End]` (plus `last_match(true)`). On an empty query skim's
`MatchAllEngine` scores every row `(score=0, begin=0)`, so with all
scores tied the whole list is ordered by the second criterion.
`PathName`'s key is `path_name_offset - begin`, which at `begin=0`
collapses to `path_name_offset` — the byte offset after the last `/`.
Names without a slash get `0` (kept in input order); names with one get
a positive offset and sink. `PathName` was added to rank typed-query
matches on their leaf segment (`feature/auth` on `auth`), but skim is a
plain crates.io dependency with no engine-injection hook, so it can't be
made inert on an empty query without forking skim — which this project
deliberately avoids.
**Fix.** Revert to skim's default `[Score, Begin, End]` and drop
`last_match(true)`. On an empty query every row ties on `[0,0,0]`, so
skim's stable sort preserves input order, which is collect's order:
current, main, newest-first worktrees, then branches, then the appended
`--prs` rows. The cost is confined to typed queries — leaf-segment
matches no longer win a *score tie* — but the shared `~/workspace/`
prefix is already stripped from each row's match text in
`progressive_handler::on_skeleton`, and the frizbee matcher penalizes
non-boundary matches, so `feature/auth` still ranks well on `auth`.
A regression test creates a `feature/auth` worktree before a
`plain-branch` one and asserts `feature/auth` stays ahead on the
empty-query view; it was confirmed to fail on the old tiebreak and pass
on the new one.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:29:23 -07:00
|
|
|
|
// skim 4.8's default tiebreak, kept explicit so a future edit can't
|
|
|
|
|
|
// quietly reintroduce `PathName` here. The empty-query view (no characters typed)
|
|
|
|
|
|
// is the picker's default frame, and it must show rows in the order
|
|
|
|
|
|
// `collect` produced them: current, main, newest-first worktrees, then
|
|
|
|
|
|
// branches, then the appended `--prs` rows. skim's empty-query engine
|
|
|
|
|
|
// (`MatchAllEngine`) scores every row `(score=0, begin=0)`, so with all
|
|
|
|
|
|
// scores tied the *whole* list is ordered by the second criterion. A
|
|
|
|
|
|
// `PathName` there degenerates: its key is `path_name_offset - begin`, so
|
|
|
|
|
|
// at `begin=0` it collapses to `path_name_offset` — sorting every row by
|
|
|
|
|
|
// where its last `/` sits. That pulls any slash-bearing name out of
|
|
|
|
|
|
// collect order regardless of row kind: a `feature/…` PR head branch, a
|
|
|
|
|
|
// `perf/…` worktree branch, and the `/`-gutter local-branch rows all sink
|
|
|
|
|
|
// together. `Begin`/`End` are `0` on the empty query, so they don't
|
|
|
|
|
|
// perturb it, leaving collect's input order intact.
|
feat(switch): rank picker matches by the distinct worktree path (#3208)
The interactive `wt switch` picker built the string skim's matcher sees
(`search_text`) from each row's absolute worktree path. Every row
therefore shared the same `~/workspace/` prefix, and skim's default
`[Score, Begin, End]` tiebreak (on a score tie it prefers the earliest
match position) biased matches toward that shared noise. Two coupled
changes make the matcher rank on the part of a row that actually
distinguishes it.
## Part A: path-scheme tiebreak
skim's `Path` scheme sets `last_match = true` (prefer the query's
rightmost occurrence) and front-loads the `PathName` tiebreak criterion
(rank matches in the leaf segment, at or after the last `/`, above
matches in a parent directory). Set directly on the builder:
```rust
.last_match(true)
.tiebreak(vec![
RankCriteria::Score,
RankCriteria::PathName,
RankCriteria::Begin,
RankCriteria::End,
])
```
These are set as the two underlying knobs rather than
`.scheme(MatchScheme::Path)`. The brief flagged `.scheme()` as a silent
no-op through the library builder, but that is not the case for skim
4.8.0: `SkimOptionsBuilder::build()` is a manual wrapper that calls
`SkimOptions::build()` (`self.final_build().map(SkimOptions::build)`),
and `SkimOptions::build()` is exactly where the scheme expands. Verified
empirically: `.scheme(MatchScheme::Path)` yields `last_match=true` and
tiebreak `[Score, PathName, Score, Begin, End]`. So `.scheme()` would
work; the reason to set the knobs directly is that the scheme inserts a
duplicate `Score` criterion (harmless, since the first wins, but an
artifact), whereas the direct form gives the clean `[Score, PathName,
Begin, End]`.
The scrollbar gap that motivated the original concern is a different
mechanism: `scrollbar`'s default comes from a clap `default_value`
attribute that the derive-builder path doesn't apply (so it falls back
to `String::default()`), not from `SkimOptions::build()` going uncalled.
### Known interaction
`PathName` reads the whole `search_text`, including the trailing gutter
glyph each row folds in for sigil-filtering. Local-branch rows use `/`
as that glyph, which `PathName` reads as a path separator, so on a score
tie a local-branch row sorts just under a worktree/remote row whose
glyph (`+`/`@`/`^`/`|`) is not a separator. The effect is confined to
exact ties (`PathName` is the second criterion) and only reorders rows;
the alternative (changing the gutter sigils or dropping the path scheme)
costs more than the tie-order quirk, so it rides along, documented in
the code.
## Part B: distinct path
`search_text` now strips the shared worktree parent from each worktree
path, so the matcher indexes only the distinguishing tail
(`worktrunk.skim-features`, not the full absolute path). The base is
`list_worktrees()[0].path.parent()`, computed once before the per-row
loop; a worktree outside that parent keeps its full path via the
`strip_prefix` fallback.
I first used `primary_worktree()` for the base, but review surfaced
three problems with it, all of which dissolve by sourcing the base from
`list_worktrees()` instead:
- `primary_worktree()` is not network-free for bare repos: it routes
through `default_branch()`, which can `git ls-remote` on the first call
per repo.
- `primary_worktree()` resolves to `repo_path()`, which is
`dunce::canonicalize`d. The row paths come from `list_worktrees()` (raw
`git worktree list --porcelain`). Those are two different derivations,
so they could in principle diverge (e.g. `/private/var` vs `/var`) and
silently defeat the strip. Sourcing the base from `list_worktrees()` too
means base and rows share one canonicalization, so the strip can't miss.
- `primary_worktree()` returns `None` for a bare repo whose default
branch has no worktree; `list_worktrees()[0]` yields a base whenever any
worktree exists.
`list_worktrees()[0]` is the main worktree for normal repos and the
first linked worktree for bare ones; either way its parent is the shared
sibling parent. It is already cached by the time the skeleton renders
(the rows were just built from it), so this adds no network or extra git
work.
Only `search_text` changes; the rendered Path column is a separate field
and is untouched. `--prs` rows build their own `search_text` with no
worktree path, so they are unaffected.
## Verification
- New unit test `search_text_strips_shared_worktree_parent` pins the
exact stripped format and the out-of-tree fallback, reading the inside
row's path back via `worktree_for_branch` so it strips a real
`list_worktrees()` path.
- Drove the picker interactively against a repo with namespaced
worktrees (`api/users`, `users/api`, `db/users`, `feature/payment`,
`payment/refund`). Querying `users` matched only the three `*users*`
rows, not all six. Querying `claude`, a fragment that lives only in the
now-stripped shared parent (`~/.claude/jobs/...`), matched zero rows,
where before it would have fuzzy-matched `.claude` on every row.
- Multi-angle review (8 finder angles + adversarial verify) drove the
move to `list_worktrees()` and the documented tiebreak interaction
above.
- `cargo run -- hook pre-merge --yes` passes (4180 tests, lints).
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:28:01 -07:00
|
|
|
|
//
|
fix(picker): keep the empty-query list in collect order (#3301)
The `wt switch` picker's default view (no query typed) reordered rows by
where each name's last `/` falls, instead of showing them in collect
order. Any slash-bearing row — a `feature/…` PR head branch, a `perf/…`
worktree branch, or a `/`-gutter local branch — sank toward the bottom
and intermixed with rows of other kinds. A worktree like
`perf/list-prune-…` would appear stranded among the `#` PR rows rather
than up with the other worktrees.
**Cause.** The picker set a custom skim tiebreak `[Score, PathName,
Begin, End]` (plus `last_match(true)`). On an empty query skim's
`MatchAllEngine` scores every row `(score=0, begin=0)`, so with all
scores tied the whole list is ordered by the second criterion.
`PathName`'s key is `path_name_offset - begin`, which at `begin=0`
collapses to `path_name_offset` — the byte offset after the last `/`.
Names without a slash get `0` (kept in input order); names with one get
a positive offset and sink. `PathName` was added to rank typed-query
matches on their leaf segment (`feature/auth` on `auth`), but skim is a
plain crates.io dependency with no engine-injection hook, so it can't be
made inert on an empty query without forking skim — which this project
deliberately avoids.
**Fix.** Revert to skim's default `[Score, Begin, End]` and drop
`last_match(true)`. On an empty query every row ties on `[0,0,0]`, so
skim's stable sort preserves input order, which is collect's order:
current, main, newest-first worktrees, then branches, then the appended
`--prs` rows. The cost is confined to typed queries — leaf-segment
matches no longer win a *score tie* — but the shared `~/workspace/`
prefix is already stripped from each row's match text in
`progressive_handler::on_skeleton`, and the frizbee matcher penalizes
non-boundary matches, so `feature/auth` still ranks well on `auth`.
A regression test creates a `feature/auth` worktree before a
`plain-branch` one and asserts `feature/auth` stays ahead on the
empty-query view; it was confirmed to fail on the old tiebreak and pass
on the new one.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:29:23 -07:00
|
|
|
|
// The cost of omitting `PathName` is confined to typed queries (scores
|
|
|
|
|
|
// only tie once a query matches): leaf-segment matches no longer win an
|
|
|
|
|
|
// exact tie. Two existing mechanisms already cover most of that — the
|
|
|
|
|
|
// shared `~/workspace/` prefix is stripped from each row's `search_text`
|
|
|
|
|
|
// in `progressive_handler::on_skeleton`, and frizbee penalizes
|
|
|
|
|
|
// non-boundary matches — so `feature/auth` still ranks well on `auth`.
|
feat(switch): rank picker matches by the distinct worktree path (#3208)
The interactive `wt switch` picker built the string skim's matcher sees
(`search_text`) from each row's absolute worktree path. Every row
therefore shared the same `~/workspace/` prefix, and skim's default
`[Score, Begin, End]` tiebreak (on a score tie it prefers the earliest
match position) biased matches toward that shared noise. Two coupled
changes make the matcher rank on the part of a row that actually
distinguishes it.
## Part A: path-scheme tiebreak
skim's `Path` scheme sets `last_match = true` (prefer the query's
rightmost occurrence) and front-loads the `PathName` tiebreak criterion
(rank matches in the leaf segment, at or after the last `/`, above
matches in a parent directory). Set directly on the builder:
```rust
.last_match(true)
.tiebreak(vec![
RankCriteria::Score,
RankCriteria::PathName,
RankCriteria::Begin,
RankCriteria::End,
])
```
These are set as the two underlying knobs rather than
`.scheme(MatchScheme::Path)`. The brief flagged `.scheme()` as a silent
no-op through the library builder, but that is not the case for skim
4.8.0: `SkimOptionsBuilder::build()` is a manual wrapper that calls
`SkimOptions::build()` (`self.final_build().map(SkimOptions::build)`),
and `SkimOptions::build()` is exactly where the scheme expands. Verified
empirically: `.scheme(MatchScheme::Path)` yields `last_match=true` and
tiebreak `[Score, PathName, Score, Begin, End]`. So `.scheme()` would
work; the reason to set the knobs directly is that the scheme inserts a
duplicate `Score` criterion (harmless, since the first wins, but an
artifact), whereas the direct form gives the clean `[Score, PathName,
Begin, End]`.
The scrollbar gap that motivated the original concern is a different
mechanism: `scrollbar`'s default comes from a clap `default_value`
attribute that the derive-builder path doesn't apply (so it falls back
to `String::default()`), not from `SkimOptions::build()` going uncalled.
### Known interaction
`PathName` reads the whole `search_text`, including the trailing gutter
glyph each row folds in for sigil-filtering. Local-branch rows use `/`
as that glyph, which `PathName` reads as a path separator, so on a score
tie a local-branch row sorts just under a worktree/remote row whose
glyph (`+`/`@`/`^`/`|`) is not a separator. The effect is confined to
exact ties (`PathName` is the second criterion) and only reorders rows;
the alternative (changing the gutter sigils or dropping the path scheme)
costs more than the tie-order quirk, so it rides along, documented in
the code.
## Part B: distinct path
`search_text` now strips the shared worktree parent from each worktree
path, so the matcher indexes only the distinguishing tail
(`worktrunk.skim-features`, not the full absolute path). The base is
`list_worktrees()[0].path.parent()`, computed once before the per-row
loop; a worktree outside that parent keeps its full path via the
`strip_prefix` fallback.
I first used `primary_worktree()` for the base, but review surfaced
three problems with it, all of which dissolve by sourcing the base from
`list_worktrees()` instead:
- `primary_worktree()` is not network-free for bare repos: it routes
through `default_branch()`, which can `git ls-remote` on the first call
per repo.
- `primary_worktree()` resolves to `repo_path()`, which is
`dunce::canonicalize`d. The row paths come from `list_worktrees()` (raw
`git worktree list --porcelain`). Those are two different derivations,
so they could in principle diverge (e.g. `/private/var` vs `/var`) and
silently defeat the strip. Sourcing the base from `list_worktrees()` too
means base and rows share one canonicalization, so the strip can't miss.
- `primary_worktree()` returns `None` for a bare repo whose default
branch has no worktree; `list_worktrees()[0]` yields a base whenever any
worktree exists.
`list_worktrees()[0]` is the main worktree for normal repos and the
first linked worktree for bare ones; either way its parent is the shared
sibling parent. It is already cached by the time the skeleton renders
(the rows were just built from it), so this adds no network or extra git
work.
Only `search_text` changes; the rendered Path column is a separate field
and is untouched. `--prs` rows build their own `search_text` with no
worktree path, so they are unaffected.
## Verification
- New unit test `search_text_strips_shared_worktree_parent` pins the
exact stripped format and the out-of-tree fallback, reading the inside
row's path back via `worktree_for_branch` so it strips a real
`list_worktrees()` path.
- Drove the picker interactively against a repo with namespaced
worktrees (`api/users`, `users/api`, `db/users`, `feature/payment`,
`payment/refund`). Querying `users` matched only the three `*users*`
rows, not all six. Querying `claude`, a fragment that lives only in the
now-stripped shared parent (`~/.claude/jobs/...`), matched zero rows,
where before it would have fuzzy-matched `.claude` on every row.
- Multi-angle review (8 finder angles + adversarial verify) drove the
move to `list_worktrees()` and the documented tiebreak interaction
above.
- `cargo run -- hook pre-merge --yes` passes (4180 tests, lints).
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:28:01 -07:00
|
|
|
|
.tiebreak(vec![
|
|
|
|
|
|
RankCriteria::Score,
|
|
|
|
|
|
RankCriteria::Begin,
|
|
|
|
|
|
RankCriteria::End,
|
|
|
|
|
|
])
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
// Fill the whole selected row with the `current` background (set via
|
|
|
|
|
|
// `current_bg` in `.color(...)` below). skim 4.x applies the current-row
|
|
|
|
|
|
// style at the line level only when this is on; without it the selection
|
|
|
|
|
|
// shows just the `>` pointer (the row's own `display()` ANSI spans carry
|
|
|
|
|
|
// no background). skim 0.20's tuikit backend highlighted the row for free.
|
|
|
|
|
|
.highlight_line(true)
|
fix(switch): keep the picker gutter sigil when filtering (#3213)
The `wt switch` picker dropped each row's leading gutter sigil
(`+`/`@`/`^`/`/`/`|`) when you typed filter characters — in the reported
case, every row but the top one lost its sigil after typing a couple of
letters.
The cause is that skim's horizontal scroll is a second layout authority
over a row that worktrunk already lays out. On a query match, skim
scrolls the matched row left to bring the matched character into view,
deriving the scroll offset from that character's *position* in the match
text (`search_text` = branch + full path + glyph — far longer than the
visible row) while clamping against the rendered line's own width. Any
row whose rendered width exceeds skim's `container_width` — a long
branch name, or a width-count disagreement on wide glyphs (worktrunk
measures with `unicode-width`, skim with `unicode-display-width`) — then
shifts left far enough that its leading gutter sigil falls off the left
edge. The top row survives because its best-ranked match sits near char
0, so its shift is 0.
The fix sets `no_hscroll(true)` on the picker's skim options. worktrunk
already owns row layout and right-truncates each row to the list width,
so skim's match-driven hscroll is a redundant, conflicting mechanism —
and it never served its purpose here anyway, since the picker's
`display()` ignores skim's match context and renders its own ANSI. With
hscroll off, an overflowing row truncates on the right (gutter kept)
instead of scrolling left; non-overflowing rows are unchanged.
Verified by reproducing with a long branch name (before: `>
zzz-very-long-…` with the sigil gone; after: `> / zzz-very-long-…` with
the `/` restored). The picker's skim options are config wiring built
inline in `handle_picker` with no existing unit coverage, so this
carries no new test — the rationale lives in a comment so it survives
future skim upgrades.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:45:33 -07:00
|
|
|
|
// Each row's `display()` owns its layout: a leading gutter sigil
|
|
|
|
|
|
// (`+`/`@`/`^`/`/`/`|`), then columns, right-truncated to the list width
|
|
|
|
|
|
// with a trailing `…`. skim's horizontal scroll is a second, conflicting
|
|
|
|
|
|
// layout authority over the same row — on a query match it scrolls the row
|
|
|
|
|
|
// left to bring the matched char into view, deriving the offset from that
|
|
|
|
|
|
// char's *position* in the match text (`search_text` = branch + full path +
|
|
|
|
|
|
// glyph), which is far longer than the visible row, while clamping against
|
|
|
|
|
|
// the rendered line's own width. Any row whose rendered width exceeds skim's
|
|
|
|
|
|
// container (e.g. a long branch name, or a width-count disagreement on wide
|
|
|
|
|
|
// glyphs) then gets shifted left far enough to clip its leading gutter sigil
|
|
|
|
|
|
// — typing a few chars made the sigil vanish from every overflowing row.
|
|
|
|
|
|
// Disabling hscroll leaves worktrunk as the sole row-layout
|
|
|
|
|
|
// authority: overflow truncates on the right (gutter kept) instead of
|
|
|
|
|
|
// scrolling left. The picker doesn't reveal matches by scrolling anyway —
|
|
|
|
|
|
// `display()` ignores the match context and renders its own ANSI.
|
|
|
|
|
|
.no_hscroll(true)
|
2026-06-24 11:10:19 -07:00
|
|
|
|
// Draw a scrollbar thumb on the item list when it overflows the view.
|
|
|
|
|
|
// skim's `▐` default is the clap `default_value`, gated on skim's `cli`
|
|
|
|
|
|
// feature; with `default-features = false` the library `Default` for
|
|
|
|
|
|
// this `String` field is empty, which skim reads as "no scrollbar".
|
|
|
|
|
|
// Setting it explicitly restores the thumb — without it a long worktree
|
|
|
|
|
|
// (or `--prs`) list scrolls with no position cue, made worse by
|
|
|
|
|
|
// `no_info(true)` below hiding the matched/total counter.
|
|
|
|
|
|
.scrollbar("▐".to_string())
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// First line (header) non-selectable; `PICKER_HEADER_ROWS` names the count.
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
.header_lines(PICKER_HEADER_ROWS)
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
.multi(false)
|
feat(picker): reclaim table width when the preview toggles (#3214)
## What
The interactive `wt switch` picker now lays its table out at full
terminal width regardless of the preview layout. Previously, in the
side-by-side (Right) layout the table was sized for the list pane (about
half the terminal), so toggling the preview off with `alt-p` left the
freed horizontal space empty.
## How
The table is laid out once at full width. skim splits the screen and
renders the full-width rows into the left pane, clipping the overflow at
the boundary; toggling the preview off widens the list pane and the same
rows reveal their right-hand columns, with no reload and no re-layout.
Because the layout is computed once, the leading columns never move when
the preview toggles.
Three small changes in `src/commands/picker/mod.rs`:
1. `skim_list_width` is the full terminal width (minus skim's 2-column
cursor gutter), instead of half width in the Right layout.
2. `no_hscroll(true)` anchors the leading columns: a fuzzy match deep in
the search key can no longer shift them out of view.
3. An empty `ellipsis` makes the clip a clean left-anchored cut with no
`..`. (Empty is already the library default under `default-features =
false`; it is pinned explicitly because the clean clip is load-bearing.)
The Down (stacked) layout already used full width, so it is unchanged.
## Tradeoff
With the preview shown, the narrow left pane now shows the leftmost
slice of the full table rather than a layout optimized to fit the pane.
Because `Remote⇅` precedes `CI` in the column order, the full-width
layout can surface an often-empty `Remote⇅` at the pane edge and push
`CI`/`Age`/`Path` under the preview. Hiding the preview reveals all of
them in their natural positions. This is the inherent shape of the
chosen approach: the leading columns stay fixed, and the right edge is
whatever the full-width table places there. The picker PTY snapshots
capture this clipped-at-the-boundary state.
## Background
This started as a design exploration weighing three approaches: (1)
re-layout the table on toggle, (2) add an orientation toggle plus
re-layout, and (3) render at full width and let the preview cover the
right. skim splits the screen rather than overlaying, so option 3
reduces to clipping a full-width row at the split boundary. It is the
smallest change and the only one that never moves the leading columns,
so it was chosen. The design proposal that compared the options has been
removed (design docs are review-only by convention); its rationale now
lives in the code comments and this PR's history.
## Testing
- `cargo run -- hook pre-merge --yes`: all 4182 tests pass, clippy and
fmt clean.
- The three picker PTY snapshots (`switch_picker_abort_escape_list`,
`switch_picker_with_branches_list`,
`switch_picker_multiple_worktrees_list`) were regenerated; they confirm
the leading columns are unchanged and the boundary now reveals the next
column.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-24 21:32:54 -07:00
|
|
|
|
// The table is laid out at full terminal width (see `skim_list_width`
|
|
|
|
|
|
// above), so while the preview is shown the rows overflow skim's
|
|
|
|
|
|
// half-width list pane. Disable horizontal scroll so a fuzzy match deep
|
|
|
|
|
|
// in the search key can never shift the leading columns out of view — the
|
|
|
|
|
|
// row always clips left-anchored at the pane boundary. An empty ellipsis
|
|
|
|
|
|
// makes that a clean cut with no "..": it is the library default under
|
|
|
|
|
|
// `default-features = false` (the `..` default is gated on skim's `cli`
|
|
|
|
|
|
// feature, off here), pinned explicitly because the clean clip is
|
|
|
|
|
|
// load-bearing for the overflow.
|
|
|
|
|
|
.no_hscroll(true)
|
|
|
|
|
|
.ellipsis(String::new())
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
.no_info(true) // Hide info line (matched/total counter)
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
.preview("") // Enable preview (empty string means use SkimItem::preview())
|
|
|
|
|
|
.preview_window(preview_window_spec.as_str())
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
// Color scheme using fzf's --color=light values: dark text (237) on light gray bg (251)
|
|
|
|
|
|
//
|
|
|
|
|
|
// Terminal color compatibility is tricky:
|
|
|
|
|
|
// - current_bg:254 (original): too bright on dark terminals, washes out text
|
|
|
|
|
|
// - current_bg:236 (fzf dark): too dark on light terminals, jarring contrast
|
|
|
|
|
|
// - current_bg:251 + current:-1: light bg works on both, but unstyled text
|
|
|
|
|
|
// becomes unreadable on dark terminals (light-on-light)
|
|
|
|
|
|
// - current_bg:251 + current:237: fzf's light theme, best compromise
|
|
|
|
|
|
//
|
|
|
|
|
|
// The light theme works universally because:
|
|
|
|
|
|
// - On dark terminals: light gray highlight stands out clearly
|
|
|
|
|
|
// - On light terminals: light gray is subtle but visible
|
|
|
|
|
|
// - Dark text (237) ensures readability regardless of terminal theme
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
.color("fg:-1,bg:-1,header:-1,matched:108,current:237,current_bg:251,current_match:108")
|
2026-03-23 12:18:42 -07:00
|
|
|
|
.cmd_collector(Rc::new(RefCell::new(collector)) as Rc<RefCell<dyn CommandCollector>>)
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
.bind(vec![
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
// Preview-tab switching (alt-1..alt-7 jump to a tab; tab / shift-tab
|
|
|
|
|
|
// cycle) is installed natively below via `install_preview_tab_keybindings`
|
|
|
|
|
|
// rather than here — those keys run Rust callbacks, not shell commands.
|
|
|
|
|
|
// Bare digits 1-7 stay unbound so they flow to the query input (a PR
|
|
|
|
|
|
// number, or digits within a branch name).
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
//
|
|
|
|
|
|
// Create new worktree with query as branch name (alt-c for "create")
|
|
|
|
|
|
"alt-c:accept(create)".to_string(),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// alt-x (remove) is installed natively below via
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// `install_remove_keybinding` — a Custom callback that runs the removal
|
|
|
|
|
|
// synchronously and rebuilds skim's pool in place (no `reload`, so no
|
|
|
|
|
|
// cursor flash), which a string bind can't express.
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// Refresh the list (alt-r for "refresh"): `reload(refresh)` re-runs
|
|
|
|
|
|
// collect through PickerCollector, picking up worktrees/branches
|
|
|
|
|
|
// created outside the session (a teammate's push, a parallel agent)
|
|
|
|
|
|
// without reopening the picker.
|
|
|
|
|
|
"alt-r:reload(refresh)".to_string(),
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
// Preview toggle (alt-p shows/hides preview)
|
|
|
|
|
|
// Note: skim doesn't support change-preview-window like fzf, only toggle
|
|
|
|
|
|
"alt-p:toggle-preview".to_string(),
|
fix(switch): suppress skim's alt-l/alt-h horizontal scroll in the picker (#3226)
## What
`alt-l` (and its mirror `alt-h`) horizontally scrolled the `wt switch`
picker's list, sliding each row left under the fixed cursor gutter and
clipping the leading worktree-status sigil (`@`/`^`/`+`/`⊂`) and the
branch name — with no ellipsis to mark the cut. This binds both keys to
skim's `ignore` no-op so the carefully laid-out table stops sliding.
## Why it happened
The picker never bound `alt-l`/`alt-h`, so they fell through to skim
4.8's default keymap, where they map to `ScrollRight(1)` /
`ScrollLeft(1)`. Those actions mutate skim's `manual_hscroll` offset.
`no_hscroll(true)` (added in #3213 specifically to keep the sigil gutter
stable) only zeros the *automatic*, match-following shift —
`calc_hscroll_for_width` still adds `manual_hscroll` on top, so the
manual-scroll keys escaped the guard entirely.
Two visible symptoms, both reproduced live in a real picker:
- One `alt-l` press shifts every row's content left by a column; the
leading sigil scrolls off and one extra char appears on the right.
Repeated presses chew further into the branch name
(`picker-alt-l-gutter` → `er-alt-l-gutter`). The skim `>` cursor lives
in a separate prefix gutter that is *not* scrolled, so it visibly
detaches from the content it normally sits beside.
- `manual_hscroll` is an `i32` clamped to `>= 0` only at render time
(`.max(0)`), not at the state level, so `alt-h` drives it negative.
After scrolling back you must "pay off" that negative backlog with
`alt-l` before scrolling resumes — the keys feel unresponsive.
## Fix
```rust
"alt-h:ignore".to_string(),
"alt-l:ignore".to_string(),
```
`alt-h`/`alt-l` are the *only* default keys that produce
`ScrollLeft`/`ScrollRight`, and skim's query word-navigation uses
`alt-b`/`alt-f`/`alt-d` instead — so suppressing these two fully closes
the manual-hscroll path with no collateral to query editing. The
preview-tab keys (`alt-1`…`alt-7`, `Tab`) are unaffected (verified
live).
This is the manual-scroll counterpart to #3213's `no_hscroll(true)`;
that PR closed the automatic path, this one closes the manual path the
same guard left open.
## Testing
Adds `test_switch_picker_alt_l_does_not_hscroll`, a PTY test that drives
`alt-l`/`alt-l`/`alt-h` and snapshots the list with its gutter sigils
intact. Verified the test **fails** when the fix is removed (the `@`/`+`
sigils clip: `> @ main` → `> main`) and passes with it. Full `pre-merge`
gate green (4199 tests, all lints, `--features
shell-integration-tests`).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:40:02 -07:00
|
|
|
|
// Suppress skim's default manual horizontal scroll (alt-h / alt-l map to
|
|
|
|
|
|
// ScrollLeft / ScrollRight in its built-in keymap). `no_hscroll(true)`
|
|
|
|
|
|
// above only zeros the *automatic* match-following shift; it doesn't gate
|
|
|
|
|
|
// the manual `manual_hscroll` offset these keys push, so they still slide
|
|
|
|
|
|
// each row's `display()` left under the fixed gutter — clipping the leading
|
|
|
|
|
|
// worktree-status sigil (`+`/`@`/`^`/`/`/`|`) and the branch name with no
|
|
|
|
|
|
// ellipsis. The row table is laid out to fit the pane, so there is nothing
|
|
|
|
|
|
// to scroll to; ignore both.
|
|
|
|
|
|
"alt-h:ignore".to_string(),
|
|
|
|
|
|
"alt-l:ignore".to_string(),
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
// Preview scrolling (half-page based on terminal height)
|
|
|
|
|
|
format!("ctrl-u:preview-up({half_page})"),
|
|
|
|
|
|
format!("ctrl-d:preview-down({half_page})"),
|
|
|
|
|
|
])
|
|
|
|
|
|
// Legend/controls moved to preview window tabs (render_preview_tabs)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("Failed to build skim options: {}", e))?;
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
// `.build()` parsed the string binds above into `options.keymap`; layer the
|
|
|
|
|
|
// preview-tab switches on top as native `Action::Custom` callbacks (skim's
|
|
|
|
|
|
// string bind API can't express a custom action).
|
|
|
|
|
|
install_preview_tab_keybindings(&mut options.keymap);
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// Row shortcuts (alt-y copy branch, alt-o open PR/MR URL) — native callbacks
|
|
|
|
|
|
// that read the selected row off skim's `App` and run the OS action on a
|
|
|
|
|
|
// background thread. Like the preview-tab keys, they can't be string binds.
|
|
|
|
|
|
install_shortcut_keybindings(&mut options.keymap, Arc::clone(&shortcut_table));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// alt-x (remove): a Custom callback that runs the removal synchronously and
|
|
|
|
|
|
// rebuilds skim's pool in place — no `reload`, so the cursor never flashes to
|
|
|
|
|
|
// the top. Moves the `AltXRemover` in (the callback must be `Send`).
|
|
|
|
|
|
install_remove_keybinding(&mut options.keymap, alt_x_remover);
|
refactor(trace): unify in-process trace-trigger surface (#2554)
Three small consolidations in the trace-trigger surface, following up on
#2539.
## What this changes
**1. `Cmd::stream()` emits `[wt-trace] cmd=...` natively.** Previously
only `Cmd::run()` and `Cmd::pipe_into()` emitted per-subprocess records
— `stream()` was a hole, which #2539 patched with a
`Span::new(\"execute_shell_command\")` wrapper at the foreground
hook/alias call site. The two surrogates aren't equivalent: spans render
under `cat: \"wt\"`, subprocess records under `cat:
\"git\"`/`\"network\"`, and spans don't carry the `ok` flag, so
hook/alias child status was invisible in chrome traces. Stream now emits
a record at every exit point (spawn fail, stdin write, wait fail,
signal-derived exit, SIGPIPE-as-success, non-zero status, success) via a
small `WtTraceLog` helper that mirrors `ExternalCommandLog`'s shape.
**2. Drops the `Span::new(\"execute_shell_command\")` workaround** in
`commands/command_executor.rs`. With `Cmd::stream()` emitting natively,
the wrapper is redundant — foreground hook/alias step time is now
captured by the canonical subprocess record (cat=`git`/`network`/none)
instead of a generic span (cat=`wt`).
**3. Re-exports `trace::instant` from `trace::mod`** alongside `Span`.
Deletes the `shell_exec::trace_instant` shim (a one-line re-export of
`trace::emit::instant`) and migrates all 14 callers in
`commands/picker/mod.rs` and `commands/list/collect/mod.rs` to
`worktrunk::trace::instant`. Symmetric public API: `trace::Span` for
scopes, `trace::instant` for milestones — neither lives under
`shell_exec` anymore, since neither has anything to do with shell
execution.
## Verification
Smoke-tested with `RUST_LOG=debug wt <alias>`: `cmd=\"echo
hello-from-stream\" ok=true` and `cmd=\"exit 7\" ok=false` both fire
correctly. `Span(\"execute_shell_command\")` no longer appears in the
trace. Full pre-merge hook (3430 tests + clippy + lints) passes locally.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:14:19 -07:00
|
|
|
|
worktrunk::trace::instant("Picker skim options built");
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
fix(picker): stop burning a core while background collect is pending (#3534)
`wt switch` burned ~100% of one core whenever the picker looked idle but
background collect work was still pending: a slow CI fetch, or an LLM
branch summary (`[list] summary = true` with a `commit.generation`
command). skim's reader polls its item channel with kanal's
`recv_timeout(1ms)`, and kanal's timeout wait yields in a loop instead
of parking, so an open-but-empty channel spins a full core for as long
as any `SkimItemSender` is alive. The handler held its sender for the
whole collect, a contract inherited from the skim 0.20 tuikit backend,
whose 100ms repaint heartbeat needed a live reader. In skim 5.x,
in-place row updates surface via injected `Event::Render` and never
touch the channel, so nothing needs the sender past the skeleton batch.
The fix consumes the handler's sender at its single send. The channel
now closes once the last batch is in (the skeleton, or the `--prs`
rows), and skim's reader exits while collect keeps grinding through
in-place updates.
Measured on the wt-perf picker-test repo, 10s window on an idle picker
with a `sleep 30` generation command pending: debug 98% → 1.5% of a
core, release 99% → 0.6%. A truly idle picker was already fine (~1%);
the burn only ever ran while a sender stayed alive. `/usr/bin/sample`
pinned the spin to `skim::reader::collect_items` →
`kanal::signal::Signal::wait_timeout` (4027 of 4095 frames).
Two bounded windows remain: picker startup until the skeleton batch
lands, and `--prs` until the forge call returns. Eliminating those needs
an upstream skim fix (the collect loop parking instead of polling); the
busy-poll is unchanged through skim 5.4.0.
Testing: a new unit test asserts the channel closes at the skeleton
send; the picker unit tests and the PTY `switch_picker` suite (which
exercise fast-skeleton EOF, preview auto-refresh, `--prs` streaming, and
alt-r reload) pass.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:02:47 -07:00
|
|
|
|
// Spawn the collect pipeline (and the `--prs` thread when active). Each
|
|
|
|
|
|
// sender drops with its one batch sent (skeleton, PR rows), so skim's
|
|
|
|
|
|
// reader sees EOF as soon as the last batch lands — see
|
|
|
|
|
|
// `PipelineFactory::spawn`. The initial spawn reuses the startup-primed
|
|
|
|
|
|
// inventory (`false`); every alt-r refresh re-runs `factory.spawn(true)`
|
|
|
|
|
|
// to re-enumerate.
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let SpawnedPipeline {
|
|
|
|
|
|
rx,
|
|
|
|
|
|
handler,
|
|
|
|
|
|
collect_handle,
|
|
|
|
|
|
prs_handle,
|
2026-06-25 13:05:00 -07:00
|
|
|
|
} = factory.spawn(false)?;
|
refactor(trace): unify in-process trace-trigger surface (#2554)
Three small consolidations in the trace-trigger surface, following up on
#2539.
## What this changes
**1. `Cmd::stream()` emits `[wt-trace] cmd=...` natively.** Previously
only `Cmd::run()` and `Cmd::pipe_into()` emitted per-subprocess records
— `stream()` was a hole, which #2539 patched with a
`Span::new(\"execute_shell_command\")` wrapper at the foreground
hook/alias call site. The two surrogates aren't equivalent: spans render
under `cat: \"wt\"`, subprocess records under `cat:
\"git\"`/`\"network\"`, and spans don't carry the `ok` flag, so
hook/alias child status was invisible in chrome traces. Stream now emits
a record at every exit point (spawn fail, stdin write, wait fail,
signal-derived exit, SIGPIPE-as-success, non-zero status, success) via a
small `WtTraceLog` helper that mirrors `ExternalCommandLog`'s shape.
**2. Drops the `Span::new(\"execute_shell_command\")` workaround** in
`commands/command_executor.rs`. With `Cmd::stream()` emitting natively,
the wrapper is redundant — foreground hook/alias step time is now
captured by the canonical subprocess record (cat=`git`/`network`/none)
instead of a generic span (cat=`wt`).
**3. Re-exports `trace::instant` from `trace::mod`** alongside `Span`.
Deletes the `shell_exec::trace_instant` shim (a one-line re-export of
`trace::emit::instant`) and migrates all 14 callers in
`commands/picker/mod.rs` and `commands/list/collect/mod.rs` to
`worktrunk::trace::instant`. Symmetric public API: `trace::Span` for
scopes, `trace::instant` for milestones — neither lives under
`shell_exec` anymore, since neither has anything to do with shell
execution.
## Verification
Smoke-tested with `RUST_LOG=debug wt <alias>`: `cmd=\"echo
hello-from-stream\" ok=true` and `cmd=\"exit 7\" ok=false` both fire
correctly. `Span(\"execute_shell_command\")` no longer appears in the
trace. Full pre-merge hook (3430 tests + clippy + lints) passes locally.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:14:19 -07:00
|
|
|
|
worktrunk::trace::instant("Picker collect spawned");
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// The dry run keeps the handler: skim never runs there, so the EOF contract
|
|
|
|
|
|
// doesn't apply, and the dump below reads its rendered rows.
|
feat(list): custom template columns and cached PR numbers in the picker (#3073)
Two display features for `wt list` and the interactive picker, developed
together because they share the column-layout and progressive-rendering
machinery.
## Custom columns (`[list.custom-columns]`)
Each `[list.custom-columns.<Header>]` entry in user config adds a `wt
list` column: a minijinja template rendered per row over `branch`,
`worktree_path`, `worktree_name`, and `vars.*`, with optional `width`
and drop priority. Values expand before the skeleton renders, from
in-memory data only — `vars` come from the bulk git-config snapshot, so
no subprocess runs per cell. Widths are measured from content like the
Branch and Path columns; a column that is empty on every row is dropped.
Unknown variables and misspelled filters abort `wt list` with the
available-variables hint; undefined values render as empty cells (the
intended sparse-column shape). `wt list --format json` gains a `columns`
map per item, and its `vars` field now reads from the snapshot too (the
previous `--get-regexp` line-parse truncated multiline values). The
picker shares the row renderer, so the columns appear there as well; a
broken definition degrades to no columns plus a stashed warning, since
collect runs while skim owns the terminal.
The key is `[list.custom-columns]`, not `[list.columns]`, to avoid
colliding with the column-visibility toggles in #3065 (which claims
`[list.columns]` as a flat map of built-in-column bools — a mutually
exclusive serde shape for the same protected key). Namespacing here lets
both land independently.
Ref #1982 — the custom-columns proposal lives in that thread. The
issue's own title is a separate directory-naming request, so this
doesn't close it.
## Cached PR/MR numbers in the picker
The picker skips the networked CiStatus task, so until now it had no CI
column at all. Cached statuses are local data, though: collect now fills
rows from `.git/wt/cache/ci-status/` when the task is skipped under a
progressive handler, so PR/MR numbers fetched by earlier `wt list
--full` or statusline runs render in the picker — aligned with the same
`MaxPrNumber` ratchet width `wt list` uses, and with zero network
access.
A valid cache entry renders as-is. An entry whose TTL passed or whose
branch head moved keeps its PR/MR number dimmed: the number still
identifies the PR when the pipeline color may be outdated. Expired
entries without a number are dropped. The CI column is allocated only
when some row had a usable entry, and rows the cache can't fill resolve
to blank rather than a pending placeholder, since no task repaints them.
## Key files
- `src/config/expansion.rs`, `src/config/user/sections.rs`,
`src/git/repository/config.rs` — column resolution, the template
environment, and the bulk git-config snapshot.
- `src/commands/list/layout.rs`, `src/commands/list/render.rs` — column
width allocation and cell rendering.
- `src/commands/list/ci_status/mod.rs` — `populate_from_cache`, the
cache-only fill.
- `src/commands/picker/mod.rs` — the dry-run dump
(`WORKTRUNK_PICKER_DRY_RUN`) that makes picker row content assertable in
tests.
## Testing
Integration tests cover both features: custom columns (table render,
JSON output, empty-column drop, invalid-template error) and the picker
(cached PR numbers appear in the dry-run dump, uncached branches stay
blank). Unit tests cover the cache-population logic (valid,
expired-with-number, head-moved, dropped). Verified against the full
`cargo run -- hook pre-merge --yes` gate locally.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:37:22 -07:00
|
|
|
|
let dry_run_handler = is_dry_run.then_some(handler);
|
feat(switch): add AI summary preview tab (#1049)
* feat(switch): add AI summary preview tab (tab 5)
Add a fifth preview mode to `wt switch` that shows AI-generated branch
summaries using the configured [commit.generation] LLM command. Summaries
use commit-message format (imperative subject + body) and render through
the standard markdown help renderer for consistent styling.
- Background thread generates summaries in parallel for all branches
- Disk cache in .git/wt-cache/summaries/ with hash-based invalidation
- Graceful fallback: config hint when LLM not configured, dim "no changes"
for default branch
- Shortened tab labels to fit 5 tabs: 1:diff | 2:log | 3:main | 4:upstream | 5:summary
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve merge conflicts from jj revert
After merging main (which reverted jj support), fix two issues:
- Resolve config to access commit_generation (handle_select no longer
receives resolved config directly)
- Restore pub(crate) visibility on execute_llm_command
Co-Authored-By: Claude <noreply@anthropic.com>
* test: add cache and rendering tests for summary module
Add tests for cache round-trip, hash invalidation, file path
sanitization, directory structure, and pre-styled text rendering
to improve codecov/patch coverage.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: add integration test for summary tab and expand cache tests
- Add PTY test for tab 5 showing config hint when LLM not configured
- Add cache round-trip, invalidation, sanitized path, and dir tests
- Add pre-styled text rendering test for dim "no changes" messages
Co-Authored-By: Claude <noreply@anthropic.com>
* test(summary): add coverage for diff computation and LLM generation
Add 10 new unit tests covering `compute_combined_diff`,
`generate_summary`, `generate_all_summaries`, and the single-line
`render_summary` path. Uses real temp git repos with shell-stub LLM
commands (following existing patterns from merge integration tests).
Also refactors test helpers to share git command setup and repo
initialization, eliminating duplication between test cases.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(summary): handle missing default branch + add coverage tests
- compute_combined_diff no longer bails when default_branch() returns
None — wraps branch diff in if-let, preserving working tree diff
- Fix test to use exotic branch name so default_branch() actually
returns None (infer_default_branch_locally checks "main"/"master"/etc)
- Add unit tests for items.rs Summary tab paths (main worktree, feature
branch, cache hit/miss, compute_preview delegation)
- Add error path tests for write_cache (unwritable path, permission
failure)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(summary): address PR review feedback
- Remove is_main() shortcut from compute_summary_preview — was checking
main worktree (git concept) not default branch (different concept)
- Add unicode visual cues to tab labels: 1:diff±, 3:main↕, 4:upstream⇅
- Move summary_items clone closer to its consumer in mod.rs
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(ci): revert unicode tab cues — ambiguous East Asian Width
Characters ±, ↕, ⇅ have East Asian Width "Ambiguous" which skim
renders as double-width on CI, shifting [N/M] alignment by 3 chars.
Revert to plain labels for cross-platform consistency.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(switch): restore unicode symbols in tab labels
Restore ±, ↕, ⇅ symbols to tab labels (1:diff±, 3:main↕, 4:upstream⇅).
These characters are used throughout the codebase and haven't shown
width issues in practice.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(summary): bound concurrent LLM calls with Semaphore
Use the project's existing Semaphore (from src/sync.rs) to limit
concurrent LLM calls to 8 — same pattern as HEAVY_OPS_SEMAPHORE
and CMD_SEMAPHORE.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(switch): adjust snapshot spacing for unicode tab symbols
skim-tuikit uses width_cjk() for header layout, which treats
East Asian Width "Ambiguous" characters (±, ↕) as double-width.
This shifts [N/M] left by 3 columns. Update snapshots to match.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): normalize tab bar padding for cross-platform unicode widths
Skim right-aligns the [N/M] count indicator with padding that varies
depending on whether unicode chars (±, ↕, ⇅) are rendered as single
or double width. Normalize this padding in the snapshot filter so
tests pass regardless of the terminal's East Asian Width handling.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(switch): restore original tab titles, add 5th summary tab
Revert tab labels 1-4 to their original format ("1: HEAD±", "2: log",
"3: main…±", "4: remote⇅") and add "5: summary" as a new 5th tab.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(test): handle skim count overlap with summary tab label
When the 5 restored tab labels use ambiguous-width unicode symbols (±, …, ⇅),
skim's width_cjk() treats them as double-width, leaving insufficient space for
the count indicator. This causes the count to overlap with "summary" (e.g.,
"summary1/4") or truncate it ("summar1/28"). Add a targeted regex filter that
normalizes this overlap before the generic whitespace-padded count filter runs.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(test): avoid typos lint on truncated word in snapshot regex
Use `summary?` (optional `y`) instead of `summar` to avoid the typos
spell checker flagging the partial word.
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
* fix(ci): restructure review skill workflow and fix dead sticky comment (#1056)
- Reorder as explicit numbered workflow: pre-flight checks before
expensive diff analysis to avoid redundant work
- Remove redundant "read CLAUDE.md" (already in system prompt)
- Filter dedup check by bot identity so human approvals don't
cause the bot to skip its review
- Accept brief approval bodies (matches actual bot behavior)
- Replace {owner}/{repo} placeholders with derived $REPO variable
- Add --paginate on comment fetching for large PRs
- Remove sticky comment references from skill (bot puts feedback
in review bodies, not stdout — sticky comment stopped working
after PAT switch in #1052)
- Add TODO on use_sticky_comment in workflow
- Restore gh pr comment prohibition with correct justification
Co-authored-by: Claude <noreply@anthropic.com>
* fix(shell): harden nushell wrapper and improve diagnostics (#1059)
* fix(shell): harden nushell wrapper and improve diagnostics
- Move LAST_EXIT_CODE capture inside do{} block so it reflects the
actual command exit code, not a subsequent operation
- Wrap directive processing in try/catch to ensure temp file cleanup
on error
- Include nushell vendor autoload paths in scan_for_detection_details
so `wt config show` reports nushell integration status
- Add "nu" to the supported shells hint shown on unsupported shells
- Fix detection tests to use actual nushell config line patterns
- Document why non-cd directives delegate to sh -c
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(shell): remove try/catch from nushell directive cleanup
Drop error-path cleanup for the temp directive file. On error, the file
persists in /tmp as a useful debugging artifact (the OS cleans it up).
This matches bash and fish which already use a single rm on the happy
path with no error wrapping.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(shell): PowerShell wrapper swallows -D flag as -Debug (#1057)
* fix(shell): PowerShell wrapper swallows -D flag as -Debug (#885)
The `[Parameter(ValueFromRemainingArguments)]` attribute promoted the
wrapper to an "advanced function", which adds common parameters like
-Debug and -Verbose. PowerShell then consumed `-D` as `-Debug` instead
of passing it to wt.exe — so `wt remove -D` silently lost the flag.
Replace with `$args` (automatic variable for simple functions) which
passes all arguments through unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(test): use .ps1 mock for cross-platform PowerShell test
The shell script mock (#!/bin/sh) doesn't work on Windows. Use a .ps1
script instead — pwsh can invoke it directly with &, and pwsh is already
required for this test.
Co-Authored-By: Claude <noreply@anthropic.com>
* style: apply cargo fmt to PowerShell test
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(ci): use empty body for LGTM approvals instead of fluff (#1060)
The review bot was generating summary prose like "Clean hardening of
the nushell wrapper..." when it had no issues to raise. An empty
approval is less noisy — the thumbs-up reaction is sufficient signal.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(list): handle empty repos (no commits) gracefully (#1058)
* fix(list): handle empty repos (no commits) gracefully
Skip commit-dependent tasks for unborn branches (null OID) using a
COMMIT_TASKS constant, following the existing EXPENSIVE_TASKS pattern.
Filter null OIDs from timestamp batching, accept unborn default branch
in validation, and render empty commit/age cells instead of garbage.
Closes #885
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: address codecov/patch coverage gaps
- Remove unreachable COMMIT_TASKS check from work_items_for_branch (null
OIDs only appear in worktree HEAD, never in git for-each-ref)
- Restructure json_output null OID handling to eliminate dead branch
- Pre-set default branch config in test to exercise is_unborn_head_branch path
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: remove unused BranchRef::has_commits() (dead code)
Only WorktreeInfo::has_commits() is called in the dispatch code.
BranchRef::has_commits() was never referenced, causing a codecov gap.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(switch): unify preview mode handling
All 5 preview modes now follow the same cache → compute → post-process
path in preview_for_mode, eliminating the Summary early-return special
case. Summary precomputation uses rayon (queued after tabs 1-4) instead
of a separate std::thread::spawn + thread::scope wrapper.
Threading simplified from:
rayon::spawn × (N × 4) ← tabs 1-4
std::thread::spawn ← wrapper
└── std::thread::scope ← N scoped threads
└── LLM_SEMAPHORE ← rate limit
To:
rayon::spawn × (N × 4) ← tabs 1-4 (queued first)
rayon::spawn × N ← summaries (queued last, semaphore inside)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(switch): gate summary tab behind [list] summary config
Summary generation is opt-in via `[list] summary = true` (default: false)
to avoid surprise LLM calls for users who have `[commit.generation]`
configured. Both settings are required for summaries to fire.
Adds documentation for the feature in switch help, FAQ (commands we run),
and llm-commits page (new "Picker summaries" section).
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use ResolvedConfig directly after main merge
The merge with main changed handle_select to receive ResolvedConfig
instead of UserConfig, so the .resolved() call is no longer needed.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: update summary preview snapshot for config hint
The hint text now includes [list] summary = true in addition to the
[commit.generation] example.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: replace missed shlex::try_quote with shell_escape
The shlex removal in #1065 missed one call site in the switch suggestion
context builder. Replace with shell_escape::escape to match the rest of
the codebase.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Maximilian Roos <max-sixty@users.noreply.github.com>
2026-02-16 15:15:59 -08:00
|
|
|
|
|
bench: measure `wt switch` picker preview pre-compute workload (#2721)
## Summary
- Adds `picker_preview` benchmark group measuring "process spawn → all
preview tasks drained" for `wt switch`'s interactive picker.
- Introduces `WORKTRUNK_PREVIEW_BENCH=1`, an early-exit gate inside
`handle_picker` that runs the full prelude (collect, speculative spawn,
skeleton, initial + deferred precompute, `orchestrator.wait_for_idle()`)
and returns before skim launches or any JSON / stderr I/O. Shares the
dry-run path; behavior with the env var unset is unchanged.
- Closes the coverage gap behind #2662 / #2683 / #2685 / #2704, which
were tuned against `wt list` as a proxy because no direct picker
measurement existed.
## Why this measurement
Picker submits one preview-compute task per row to the global rayon
pool. The user-visible quantity to optimize is the responsiveness window
between picker launch and "all previews ready" (j/k navigation hits
cached content). Option 1 from the task — headless wall clock to drain —
is the cleanest measurable proxy and avoids the PTY route, which hits
the documented nextest/SIGTTOU pain on `shell-integration-tests`.
PTY-driven first-interactive-ready can be a follow-up.
## Variants
- `picker_preview/warm/typical-8`
- `picker_preview/cold/typical-8`
Cold uses `BatchSize::PerIteration` (not `SmallInput`): `SmallInput`
calls `setup` for an entire batch up front and then runs timed routines
back-to-back, so only the first iter in each batch is genuinely cold —
the rest hit a freshly populated `.git/wt/cache/`. `PerIteration`
invalidates immediately before every measured iteration; setup is far
cheaper than `wt switch`, so per-iter `Instant::now` doesn't dominate.
`sample_size(10)` + `measurement_time(35s)` per #2685's lead — slow
benches don't benefit from the default 30 samples.
`cfg(unix)`-gated with a no-op `main` on Windows; the picker is
Unix-only and `wt switch` (no args) hits the unavailable path before the
env var is consulted.
## Sample run
```
picker_preview/warm/typical-8 time: [185.62 ms 191.72 ms 200.77 ms]
picker_preview/cold/typical-8 time: [209.34 ms 226.23 ms 239.29 ms]
```
## Test plan
- [x] `cargo bench --bench picker_preview` runs cleanly on both variants
- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass
- [x] New `test_picker_preview_bench_produces_no_output` asserts
`WORKTRUNK_PREVIEW_BENCH=1` keeps stdout/stderr empty (covers the
env-gated branch, locks the no-I/O contract)
- [x] Smoke test: `wt switch` with `WORKTRUNK_PREVIEW_BENCH` unset still
hits the TTY error path (user-visible behavior unchanged)
- [x] Smoke test: `WORKTRUNK_PICKER_DRY_RUN=1` still emits the cache
JSON dump (regression check)
- [x] `/review-codex` pass clean after iterating on three findings
(packed-refs fix already on `main` via #2697 once branch was rebased;
`BatchSize::PerIteration` for true per-iter invalidation; `cfg(unix)`
gate for Windows)
> _This was written by Claude Code on behalf of Maximilian Roos_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:22 -07:00
|
|
|
|
// Dry-run / preview-bench: skim is bypassed. Wait for collect (which
|
|
|
|
|
|
// spawns previews via the handler) to finish, then for the orchestrator's
|
fix(picker): stop first-keystroke freeze (#3087)
Fixes a multi-second freeze in the `wt switch` picker: with many
accumulated worktrees, typing the first character locks the UI for
seconds, then it recovers. The freeze scales with worktree count.
The picker (skim) runs its per-keystroke fuzzy matcher and result sort
on rayon's **global** thread pool. Worktrunk's collection floods that
same global pool with blocking git subprocess tasks (status, diff,
rev-list, merge-base, plus the preview orchestrator's per-mode `git
diff` / `git log`), one batch per worktree. The global pool has only `2×
CPU` workers, and each git call blocks its worker for the subprocess
lifetime. When the user types, skim's matcher queues behind that flood
and can't run until workers drain. `wt list` never froze because nothing
else contends for the pool there.
The fix moves the git-heavy collection and preview work onto a dedicated
`COLLECT_POOL`, leaving the global pool free for skim. This is the same
isolation pattern already used by `copy::COPY_POOL` and
`remove_dir::REMOVE_POOL`. The new pool is sized like the global pool
(`2× CPU`, honoring `RAYON_NUM_THREADS`), so collection throughput is
unchanged. Its only job is to keep the git work off the pool skim's
matcher uses.
## Decisions
- Collection's row pipeline and the preview orchestrator stay on the
same dedicated pool, preserving the orchestrator's intentional "one
shared pool, let workers prefer dominant pressure" design. They just
move off the pool skim needs.
- The bounded pre- and post-skeleton `rayon::scope` calls stay on the
global pool. They are O(1) in worktree count (~7 spawns), so they are
not the scaling flood.
- The single-item statusline path (`populate_item`) routes through
`COLLECT_POOL` too, purely for consistency. A single item never floods
the pool, so this path was never the problem.
- The nested log-refresh `rayon::spawn_fifo` needs no change. The free
`rayon::spawn_fifo` resolves its target via the current worker's
registry, so when called from inside a `COLLECT_POOL` worker it inherits
`COLLECT_POOL` rather than falling back to the global pool. Confirmed
against the rayon-core source.
## Testing
Ran locally, before and after below, depicting the freeze/fix
### Before
https://github.com/user-attachments/assets/55c12cec-4466-487a-b480-fd2ff03ad111
### After
https://github.com/user-attachments/assets/71a5499a-5521-4727-a6b4-ab6a12f317d5
2026-06-15 19:43:45 -07:00
|
|
|
|
// pending tasks to drain on `COLLECT_POOL`. Dry-run additionally
|
bench: measure `wt switch` picker preview pre-compute workload (#2721)
## Summary
- Adds `picker_preview` benchmark group measuring "process spawn → all
preview tasks drained" for `wt switch`'s interactive picker.
- Introduces `WORKTRUNK_PREVIEW_BENCH=1`, an early-exit gate inside
`handle_picker` that runs the full prelude (collect, speculative spawn,
skeleton, initial + deferred precompute, `orchestrator.wait_for_idle()`)
and returns before skim launches or any JSON / stderr I/O. Shares the
dry-run path; behavior with the env var unset is unchanged.
- Closes the coverage gap behind #2662 / #2683 / #2685 / #2704, which
were tuned against `wt list` as a proxy because no direct picker
measurement existed.
## Why this measurement
Picker submits one preview-compute task per row to the global rayon
pool. The user-visible quantity to optimize is the responsiveness window
between picker launch and "all previews ready" (j/k navigation hits
cached content). Option 1 from the task — headless wall clock to drain —
is the cleanest measurable proxy and avoids the PTY route, which hits
the documented nextest/SIGTTOU pain on `shell-integration-tests`.
PTY-driven first-interactive-ready can be a follow-up.
## Variants
- `picker_preview/warm/typical-8`
- `picker_preview/cold/typical-8`
Cold uses `BatchSize::PerIteration` (not `SmallInput`): `SmallInput`
calls `setup` for an entire batch up front and then runs timed routines
back-to-back, so only the first iter in each batch is genuinely cold —
the rest hit a freshly populated `.git/wt/cache/`. `PerIteration`
invalidates immediately before every measured iteration; setup is far
cheaper than `wt switch`, so per-iter `Instant::now` doesn't dominate.
`sample_size(10)` + `measurement_time(35s)` per #2685's lead — slow
benches don't benefit from the default 30 samples.
`cfg(unix)`-gated with a no-op `main` on Windows; the picker is
Unix-only and `wt switch` (no args) hits the unavailable path before the
env var is consulted.
## Sample run
```
picker_preview/warm/typical-8 time: [185.62 ms 191.72 ms 200.77 ms]
picker_preview/cold/typical-8 time: [209.34 ms 226.23 ms 239.29 ms]
```
## Test plan
- [x] `cargo bench --bench picker_preview` runs cleanly on both variants
- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass
- [x] New `test_picker_preview_bench_produces_no_output` asserts
`WORKTRUNK_PREVIEW_BENCH=1` keeps stdout/stderr empty (covers the
env-gated branch, locks the no-I/O contract)
- [x] Smoke test: `wt switch` with `WORKTRUNK_PREVIEW_BENCH` unset still
hits the TTY error path (user-visible behavior unchanged)
- [x] Smoke test: `WORKTRUNK_PICKER_DRY_RUN=1` still emits the cache
JSON dump (regression check)
- [x] `/review-codex` pass clean after iterating on three findings
(packed-refs fix already on `main` via #2697 once branch was rebased;
`BatchSize::PerIteration` for true per-iter invalidation; `cfg(unix)`
gate for Windows)
> _This was written by Claude Code on behalf of Maximilian Roos_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:22 -07:00
|
|
|
|
// drains stashed warnings and dumps the cache inventory; preview-bench
|
|
|
|
|
|
// returns immediately so the measured wall clock is just "spawn → all
|
|
|
|
|
|
// preview tasks drained", with no JSON serialization or stderr I/O in
|
|
|
|
|
|
// the hot path.
|
|
|
|
|
|
if skip_tui {
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
drop(rx);
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let _ = collect_handle.join();
|
2026-06-22 16:34:04 -07:00
|
|
|
|
// Join the `--prs` thread (present only for the dry-run, not the bench)
|
|
|
|
|
|
// so its forge fetch and row render run to completion before we dump
|
|
|
|
|
|
// and exit — this normal-exit path is what gives the streaming code its
|
|
|
|
|
|
// coverage. The PR rows it built went nowhere (`rx` is dropped); the
|
|
|
|
|
|
// dump is the worktree-preview cache, unchanged.
|
|
|
|
|
|
if let Some(handle) = prs_handle {
|
|
|
|
|
|
let _ = handle.join();
|
|
|
|
|
|
}
|
Unblock picker first render; add preview dry-run (#2210)
## Problem
On repos with many worktrees, `wt switch` shows a blank terminal for 1–2
seconds before the list appears. Skim 0.20's event loop calls
`SkimItem::preview()` synchronously before `term.draw()`
(`model/mod.rs:715-722`) — any latency inside `preview()` freezes the
whole UI, not just the preview pane. The previous implementation held a
DashMap shard write lock across a git + pager subprocess via
`entry().or_insert_with(...)`, so skim's first render blocked behind
whichever background task was currently computing the first item's
default mode.
## Changes
**Thread pool** (first commit, already reviewed upstream): dedicated
rayon pool for preview/summary pre-compute, sized `2×cores` to match the
global pool's mixed-I/O profile. Extracted `rayon_thread_count()` so the
two sites can't drift.
**Non-blocking `preview()`**: `preview_for_mode` is now a pure cache
read — hit returns content, miss returns a mode-specific placeholder
(`"○ Loading working-tree diff. Press 1 again to refresh."`). Background
tasks compute outside any DashMap lock and use `insert` after, matching
the pattern `generate_and_cache_summary` already used for LLM summaries.
Skim 0.20 doesn't expose a way to re-query preview without user
interaction (`on_item_change` at `previewer.rs:187` bails on unchanged
items), so the placeholder's "press N again" instruction is the
supported refresh path.
**`PreviewOrchestrator`**
(`src/commands/picker/preview_orchestrator.rs`): owns the cache,
dedicated pool, and a pending-task counter. `PendingGuard` decrements on
drop so a panicking task still releases the counter — otherwise
`wait_for_idle` would hang forever on any panic. Exposes
`spawn_preview`, `spawn_summary`, `wait_for_idle`, `dump_cache_json` so
the pipeline is testable without skim.
**`WORKTRUNK_PICKER_DRY_RUN`**: setting the env var runs the full
pre-compute (speculative first-item spawn, collect, full spawn loop,
summaries), waits for all tasks, prints cache inventory as JSON, and
exits instead of launching skim. Useful for diagnosing "previews never
load" bugs from scripts and as the basis for integration tests.
## Testing
Unit tests in `preview_orchestrator.rs` cover end-to-end cache
population (via real `TestRepo` + git subprocesses, no mocks),
duplicate-spawn short-circuiting, and the JSON dump format.
Verified by running `WORKTRUNK_PICKER_DRY_RUN=1 wt switch` in this repo:
14 branches × 5 modes = 70 entries, all non-empty, 5s to full cache
warm.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 22:16:42 -07:00
|
|
|
|
orchestrator.wait_for_idle();
|
bench: measure `wt switch` picker preview pre-compute workload (#2721)
## Summary
- Adds `picker_preview` benchmark group measuring "process spawn → all
preview tasks drained" for `wt switch`'s interactive picker.
- Introduces `WORKTRUNK_PREVIEW_BENCH=1`, an early-exit gate inside
`handle_picker` that runs the full prelude (collect, speculative spawn,
skeleton, initial + deferred precompute, `orchestrator.wait_for_idle()`)
and returns before skim launches or any JSON / stderr I/O. Shares the
dry-run path; behavior with the env var unset is unchanged.
- Closes the coverage gap behind #2662 / #2683 / #2685 / #2704, which
were tuned against `wt list` as a proxy because no direct picker
measurement existed.
## Why this measurement
Picker submits one preview-compute task per row to the global rayon
pool. The user-visible quantity to optimize is the responsiveness window
between picker launch and "all previews ready" (j/k navigation hits
cached content). Option 1 from the task — headless wall clock to drain —
is the cleanest measurable proxy and avoids the PTY route, which hits
the documented nextest/SIGTTOU pain on `shell-integration-tests`.
PTY-driven first-interactive-ready can be a follow-up.
## Variants
- `picker_preview/warm/typical-8`
- `picker_preview/cold/typical-8`
Cold uses `BatchSize::PerIteration` (not `SmallInput`): `SmallInput`
calls `setup` for an entire batch up front and then runs timed routines
back-to-back, so only the first iter in each batch is genuinely cold —
the rest hit a freshly populated `.git/wt/cache/`. `PerIteration`
invalidates immediately before every measured iteration; setup is far
cheaper than `wt switch`, so per-iter `Instant::now` doesn't dominate.
`sample_size(10)` + `measurement_time(35s)` per #2685's lead — slow
benches don't benefit from the default 30 samples.
`cfg(unix)`-gated with a no-op `main` on Windows; the picker is
Unix-only and `wt switch` (no args) hits the unavailable path before the
env var is consulted.
## Sample run
```
picker_preview/warm/typical-8 time: [185.62 ms 191.72 ms 200.77 ms]
picker_preview/cold/typical-8 time: [209.34 ms 226.23 ms 239.29 ms]
```
## Test plan
- [x] `cargo bench --bench picker_preview` runs cleanly on both variants
- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass
- [x] New `test_picker_preview_bench_produces_no_output` asserts
`WORKTRUNK_PREVIEW_BENCH=1` keeps stdout/stderr empty (covers the
env-gated branch, locks the no-I/O contract)
- [x] Smoke test: `wt switch` with `WORKTRUNK_PREVIEW_BENCH` unset still
hits the TTY error path (user-visible behavior unchanged)
- [x] Smoke test: `WORKTRUNK_PICKER_DRY_RUN=1` still emits the cache
JSON dump (regression check)
- [x] `/review-codex` pass clean after iterating on three findings
(packed-refs fix already on `main` via #2697 once branch was rebased;
`BatchSize::PerIteration` for true per-iter invalidation; `cfg(unix)`
gate for Windows)
> _This was written by Claude Code on behalf of Maximilian Roos_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:22 -07:00
|
|
|
|
if is_dry_run {
|
|
|
|
|
|
drain_stashed_warnings(&stashed_warnings);
|
feat(list): custom template columns and cached PR numbers in the picker (#3073)
Two display features for `wt list` and the interactive picker, developed
together because they share the column-layout and progressive-rendering
machinery.
## Custom columns (`[list.custom-columns]`)
Each `[list.custom-columns.<Header>]` entry in user config adds a `wt
list` column: a minijinja template rendered per row over `branch`,
`worktree_path`, `worktree_name`, and `vars.*`, with optional `width`
and drop priority. Values expand before the skeleton renders, from
in-memory data only — `vars` come from the bulk git-config snapshot, so
no subprocess runs per cell. Widths are measured from content like the
Branch and Path columns; a column that is empty on every row is dropped.
Unknown variables and misspelled filters abort `wt list` with the
available-variables hint; undefined values render as empty cells (the
intended sparse-column shape). `wt list --format json` gains a `columns`
map per item, and its `vars` field now reads from the snapshot too (the
previous `--get-regexp` line-parse truncated multiline values). The
picker shares the row renderer, so the columns appear there as well; a
broken definition degrades to no columns plus a stashed warning, since
collect runs while skim owns the terminal.
The key is `[list.custom-columns]`, not `[list.columns]`, to avoid
colliding with the column-visibility toggles in #3065 (which claims
`[list.columns]` as a flat map of built-in-column bools — a mutually
exclusive serde shape for the same protected key). Namespacing here lets
both land independently.
Ref #1982 — the custom-columns proposal lives in that thread. The
issue's own title is a separate directory-naming request, so this
doesn't close it.
## Cached PR/MR numbers in the picker
The picker skips the networked CiStatus task, so until now it had no CI
column at all. Cached statuses are local data, though: collect now fills
rows from `.git/wt/cache/ci-status/` when the task is skipped under a
progressive handler, so PR/MR numbers fetched by earlier `wt list
--full` or statusline runs render in the picker — aligned with the same
`MaxPrNumber` ratchet width `wt list` uses, and with zero network
access.
A valid cache entry renders as-is. An entry whose TTL passed or whose
branch head moved keeps its PR/MR number dimmed: the number still
identifies the PR when the pipeline color may be outdated. Expired
entries without a number are dropped. The CI column is allocated only
when some row had a usable entry, and rows the cache can't fill resolve
to blank rather than a pending placeholder, since no task repaints them.
## Key files
- `src/config/expansion.rs`, `src/config/user/sections.rs`,
`src/git/repository/config.rs` — column resolution, the template
environment, and the bulk git-config snapshot.
- `src/commands/list/layout.rs`, `src/commands/list/render.rs` — column
width allocation and cell rendering.
- `src/commands/list/ci_status/mod.rs` — `populate_from_cache`, the
cache-only fill.
- `src/commands/picker/mod.rs` — the dry-run dump
(`WORKTRUNK_PICKER_DRY_RUN`) that makes picker row content assertable in
tests.
## Testing
Integration tests cover both features: custom columns (table render,
JSON output, empty-column drop, invalid-template error) and the picker
(cached PR numbers appear in the dry-run dump, uncached branches stay
blank). Unit tests cover the cache-population logic (valid,
expired-with-number, head-moved, dropped). Verified against the full
`cargo run -- hook pre-merge --yes` gate locally.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:37:22 -07:00
|
|
|
|
// Final rendered rows (ANSI stripped) — lets tests assert on
|
|
|
|
|
|
// picker row content without a PTY.
|
|
|
|
|
|
let rows: Vec<String> = dry_run_handler
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.and_then(|h| h.rendered_slots.get())
|
|
|
|
|
|
.map(|slots| {
|
|
|
|
|
|
slots
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|slot| slot.lock().unwrap().ansi_strip().trim_end().to_string())
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
let dump = serde_json::json!({
|
|
|
|
|
|
"rows": rows,
|
|
|
|
|
|
"entries": orchestrator.cache_entries_json(),
|
|
|
|
|
|
});
|
|
|
|
|
|
println!("{}", serde_json::to_string_pretty(&dump)?);
|
bench: measure `wt switch` picker preview pre-compute workload (#2721)
## Summary
- Adds `picker_preview` benchmark group measuring "process spawn → all
preview tasks drained" for `wt switch`'s interactive picker.
- Introduces `WORKTRUNK_PREVIEW_BENCH=1`, an early-exit gate inside
`handle_picker` that runs the full prelude (collect, speculative spawn,
skeleton, initial + deferred precompute, `orchestrator.wait_for_idle()`)
and returns before skim launches or any JSON / stderr I/O. Shares the
dry-run path; behavior with the env var unset is unchanged.
- Closes the coverage gap behind #2662 / #2683 / #2685 / #2704, which
were tuned against `wt list` as a proxy because no direct picker
measurement existed.
## Why this measurement
Picker submits one preview-compute task per row to the global rayon
pool. The user-visible quantity to optimize is the responsiveness window
between picker launch and "all previews ready" (j/k navigation hits
cached content). Option 1 from the task — headless wall clock to drain —
is the cleanest measurable proxy and avoids the PTY route, which hits
the documented nextest/SIGTTOU pain on `shell-integration-tests`.
PTY-driven first-interactive-ready can be a follow-up.
## Variants
- `picker_preview/warm/typical-8`
- `picker_preview/cold/typical-8`
Cold uses `BatchSize::PerIteration` (not `SmallInput`): `SmallInput`
calls `setup` for an entire batch up front and then runs timed routines
back-to-back, so only the first iter in each batch is genuinely cold —
the rest hit a freshly populated `.git/wt/cache/`. `PerIteration`
invalidates immediately before every measured iteration; setup is far
cheaper than `wt switch`, so per-iter `Instant::now` doesn't dominate.
`sample_size(10)` + `measurement_time(35s)` per #2685's lead — slow
benches don't benefit from the default 30 samples.
`cfg(unix)`-gated with a no-op `main` on Windows; the picker is
Unix-only and `wt switch` (no args) hits the unavailable path before the
env var is consulted.
## Sample run
```
picker_preview/warm/typical-8 time: [185.62 ms 191.72 ms 200.77 ms]
picker_preview/cold/typical-8 time: [209.34 ms 226.23 ms 239.29 ms]
```
## Test plan
- [x] `cargo bench --bench picker_preview` runs cleanly on both variants
- [x] `cargo run -- hook pre-merge --yes` — 3667 tests pass
- [x] New `test_picker_preview_bench_produces_no_output` asserts
`WORKTRUNK_PREVIEW_BENCH=1` keeps stdout/stderr empty (covers the
env-gated branch, locks the no-I/O contract)
- [x] Smoke test: `wt switch` with `WORKTRUNK_PREVIEW_BENCH` unset still
hits the TTY error path (user-visible behavior unchanged)
- [x] Smoke test: `WORKTRUNK_PICKER_DRY_RUN=1` still emits the cache
JSON dump (regression check)
- [x] `/review-codex` pass clean after iterating on three findings
(packed-refs fix already on `main` via #2697 once branch was rebased;
`BatchSize::PerIteration` for true per-iter invalidation; `cfg(unix)`
gate for Windows)
> _This was written by Claude Code on behalf of Maximilian Roos_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:22 -07:00
|
|
|
|
}
|
Unblock picker first render; add preview dry-run (#2210)
## Problem
On repos with many worktrees, `wt switch` shows a blank terminal for 1–2
seconds before the list appears. Skim 0.20's event loop calls
`SkimItem::preview()` synchronously before `term.draw()`
(`model/mod.rs:715-722`) — any latency inside `preview()` freezes the
whole UI, not just the preview pane. The previous implementation held a
DashMap shard write lock across a git + pager subprocess via
`entry().or_insert_with(...)`, so skim's first render blocked behind
whichever background task was currently computing the first item's
default mode.
## Changes
**Thread pool** (first commit, already reviewed upstream): dedicated
rayon pool for preview/summary pre-compute, sized `2×cores` to match the
global pool's mixed-I/O profile. Extracted `rayon_thread_count()` so the
two sites can't drift.
**Non-blocking `preview()`**: `preview_for_mode` is now a pure cache
read — hit returns content, miss returns a mode-specific placeholder
(`"○ Loading working-tree diff. Press 1 again to refresh."`). Background
tasks compute outside any DashMap lock and use `insert` after, matching
the pattern `generate_and_cache_summary` already used for LLM summaries.
Skim 0.20 doesn't expose a way to re-query preview without user
interaction (`on_item_change` at `previewer.rs:187` bails on unchanged
items), so the placeholder's "press N again" instruction is the
supported refresh path.
**`PreviewOrchestrator`**
(`src/commands/picker/preview_orchestrator.rs`): owns the cache,
dedicated pool, and a pending-task counter. `PendingGuard` decrements on
drop so a panicking task still releases the counter — otherwise
`wait_for_idle` would hang forever on any panic. Exposes
`spawn_preview`, `spawn_summary`, `wait_for_idle`, `dump_cache_json` so
the pipeline is testable without skim.
**`WORKTRUNK_PICKER_DRY_RUN`**: setting the env var runs the full
pre-compute (speculative first-item spawn, collect, full spawn loop,
summaries), waits for all tasks, prints cache inventory as JSON, and
exits instead of launching skim. Useful for diagnosing "previews never
load" bugs from scripts and as the basis for integration tests.
## Testing
Unit tests in `preview_orchestrator.rs` cover end-to-end cache
population (via real `TestRepo` + git subprocesses, no mocks),
duplicate-spawn short-circuiting, and the JSON dump format.
Verified by running `WORKTRUNK_PICKER_DRY_RUN=1 wt switch` in this repo:
14 branches × 5 modes = 70 entries, all non-empty, 5s to full cache
warm.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 22:16:42 -07:00
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// Run skim (single invocation — alt-r reloads and alt-x resyncs in place, not
|
|
|
|
|
|
// re-launch). Skim receives items as the bg thread's handler sends them, and the
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
// handler pushes repaints through `render_tx` (filled inside `run_skim`)
|
|
|
|
|
|
// as it mutates rows in place.
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
//
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// Don't join `collect_handle` after skim exits: drain may still be running
|
Progressive rendering in wt switch picker (#2231)
Mirror wt list's skeleton-first model in the skim picker. Branch/path
and header render immediately; status, diff stats, counts, summaries
fill in in place as they resolve. Replaces the pre-switch 500ms blocking
freeze.
## How it works
Skim 0.20's 100ms heartbeat redraws while its item channel is open
(`!processed`). Keeping the `SkimItemSender` alive holds heartbeat open;
`SkimItem::display()` reads the current rendered string via interior
mutability, so each tick picks up in-place state updates without any
explicit poke.
- `PickerProgressHandler` trait in `src/commands/list/collect/mod.rs` —
`collect` fires `on_skeleton` once the layout is ready, `on_update` per
task result, `on_reveal` at the 200ms blank→`·` transition.
`LayoutConfig` stays inside `collect` (it's `!Sync` via a `Cell`), so
rendered strings are handed out.
- `src/commands/picker/progressive_handler.rs` — builds skim items from
the skeleton, sends through `tx`, overwrites each row's shared
`Arc<Mutex<String>>` on later events. `tx` lives inside the handler so
dropping it (when the bg thread's collect returns) stops the heartbeat.
Strips OSC 8 hyperlinks — skim's rendering pipeline mangles them into
garbage like `^[8;;…`.
- `WorktreeSkimItem` now holds the rendered line behind
`Arc<Mutex<String>>`; `text()` (matcher input) stays stable (`branch +
path`) so skim's rank cache survives in-place updates.
- `handle_picker` spawns collect on a bg thread and launches skim on the
main thread. Quick selection returns immediately — `bg_handle` isn't
joined on interactive exit (would block up to `DRAIN_TIMEOUT` on network
tasks; git subprocesses are read-only so process exit is safe).
## Simplifications enabled
- Dropped the 500ms `switch_picker.timeout` wall-clock budget — it was
the UI-freeze budget, obsolete now. Config field kept for schema compat
but ignored; users on slow repos get more data, not a truncated view.
- Shared `RowCache` consolidates what used to be duplicated render-dedup
state in two places. Fixes a partial-row reveal bug where rows whose
first result landed pre-reveal kept blank placeholders on their
still-pending cells until another result arrived (caught during
simplify).
## Base branch note
Based on `skim-cut` (#2226), now merged to main. The vendored
skim-tuikit's `write_all` fix is the reliability floor — without it,
heartbeat redraws silently drop rows past the first ~1024-byte
short-write boundary, and progressive updates look broken even though
the mechanism works.
## Test coverage
Well-covered: handler state transitions (skeleton → update → reveal),
shared cache dedup, existing picker integration/dry-run tests.
Progressive rendering in a real PTY isn't unit-tested here — there's no
skim-in-a-test harness — but the dry-run path
(`WORKTRUNK_PICKER_DRY_RUN`) exercises collect + handler end-to-end
without a TTY and continues to pass.
> _This was written by Claude Code on behalf of Maximilian._
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
|
|
|
|
// network tasks, and joining would block exit for up to DRAIN_TIMEOUT
|
fix(picker): cancel background commands when the picker exits (#3560)
## What
`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.
Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.
Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.
## Why
Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.
Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:
| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |
Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).
## Why a latch, not just a sweep
A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.
Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.
This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.
## Notes
- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
the picker stays live, so pressing Enter straight after can leave a
`git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
each call site.
- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
test is deterministic (a file marker for readiness, not a sleep).
An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.
Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.
Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
are pre-existing: the `spawn_removal` refactor re-indents the dispatch
closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
judged worth more than the metric.
- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00
|
|
|
|
// (120s). Process exit terminates the bg thread; everything it started is
|
|
|
|
|
|
// read-only, and the cancellation below stops the subprocesses it spawned,
|
|
|
|
|
|
// which process exit does not.
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
let output = run_skim(options, rx, &render_tx);
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
drop(collect_handle);
|
|
|
|
|
|
// Same rationale as `collect_handle`: don't join — the forge call may still be
|
2026-06-22 16:34:04 -07:00
|
|
|
|
// in flight, and process exit terminates the thread (its `gh`/`glab`
|
|
|
|
|
|
// subprocess is read-only).
|
|
|
|
|
|
drop(prs_handle);
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
fix(picker): stash collect warnings until skim releases the terminal (#2627)
## Summary
`collect::collect` emits warnings on stderr (stale default branch,
batch-fetch failure, drain timeout, per-row task errors). On the `wt
list` path that's fine. On `wt switch`, collect runs on a background
thread while skim's TUI owns the terminal — eprintln overlays the
rendered frame and corrupts skim's clear math, leaving fragments visible
after the user picks.
Reproducer (synthetic picker-test repo with a stale
`worktrunk.default-branch` set):
```
▲ Configured default branch ghost-branch does not exist locally
↳ To reset, run wt config state default-branch clear
```
…appears overlaid on picker rows mid-render.
## Approach
Warnings flow through a new `PickerProgressHandler::stash_warning`. The
picker holds an `Arc<Mutex<Vec<String>>>` shared with its handler,
collect appends from the bg thread, and the picker drains and emits the
lines after `Skim::run_with` returns (and in the dry-run path after the
bg thread joins). Late warnings still in flight on the bg thread fall on
the floor with the thread, per the existing "don't join after skim"
rule.
`wt list`'s stderr behavior is unchanged — when `progressive_handler` is
`None`, the same closure writes straight to stderr.
The drain-timeout warning + hint that previously hardcoded `wt list` is
now subcommand-agnostic and follows `writing-user-outputs` patterns:
`"Listing worktrees timed out after Xs"`, command at end of clause,
semicolon between alternatives, `-vv` last.
## Test infrastructure
Three small extractions made the new code testable end-to-end and
brought patch coverage up from 66.7% to 100%:
- `drain_stashed_warnings(&Mutex<Vec<String>>)` in `picker/mod.rs` —
both drain call sites collapse to one line; helper body has dedicated
unit tests.
- `format_drain_timeout_diag(received_count, &items)` in
`collect/mod.rs` — pure formatter; snapshot-tested for the no-blocked
and blocked-items paths.
- `handle_drain_timeout(drain_outcome, collect_deadline, &emit)` in
`collect/mod.rs` — wraps the previously-untestable
`DrainOutcome::TimedOut` branch (`DRAIN_TIMEOUT` is 120s with no test
seam). Three unit tests synthesize `DrainOutcome` values directly to
cover all branches: timeout-fires, intentional-truncation,
complete-outcome.
Plus a new integration test in `switch_picker_dry_run.rs` that runs the
picker in dry-run mode against a stale `worktrunk.default-branch` and
asserts the warning + reset hint reach stderr after the bg thread joins.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` — 3508 tests pass, pre-commit
clean (8 new tests across the helpers above).
- [x] `wt list` warning snapshot tests still pass — non-picker stderr
unchanged.
- [x] Manual repro: `WORKTRUNK_PICKER_DRY_RUN=1 wt switch --no-cd`
against picker-test with a stale default branch now surfaces both
warning lines on stderr after the picker exits.
> _This was written by Claude Code on behalf of @max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-06 20:39:01 -07:00
|
|
|
|
// Skim has released the terminal — emit any warnings that collect's bg
|
|
|
|
|
|
// thread stashed during the run. Late warnings (e.g. drain timeouts)
|
|
|
|
|
|
// may still be in flight; we capture whatever has landed by now and let
|
|
|
|
|
|
// the rest fall on the floor with the bg thread.
|
|
|
|
|
|
drain_stashed_warnings(&stashed_warnings);
|
|
|
|
|
|
|
fix(picker): cancel background commands when the picker exits (#3560)
## What
`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.
Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.
Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.
## Why
Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.
Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:
| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |
Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).
## Why a latch, not just a sweep
A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.
Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.
This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.
## Notes
- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
the picker stays live, so pressing Enter straight after can leave a
`git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
each call site.
- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
test is deterministic (a file marker for readiness, not a sleep).
An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.
Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.
Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
are pre-existing: the `spawn_removal` refactor re-indents the dispatch
closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
judged worth more than the metric.
- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00
|
|
|
|
// The picker is over, so its background work has no consumer left: the
|
|
|
|
|
|
// preview cache dies with the process and skim will never repaint again.
|
|
|
|
|
|
// Uncancelled, a preview diff per worktree runs to completion against a
|
|
|
|
|
|
// repo the user has already left — `wt`'s exit ends the pool's threads,
|
|
|
|
|
|
// not the git children they spawned. Applies equally to accept and abort.
|
|
|
|
|
|
//
|
|
|
|
|
|
// After the drain, not before: a `--prs` forge call killed mid-flight
|
|
|
|
|
|
// fails like any other, and stashes that failure as a warning. Cancelling
|
|
|
|
|
|
// first would drain a spurious "couldn't fetch PRs" onto the user.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Not everything running here is discardable — an `alt-x` removal is
|
|
|
|
|
|
// dispatched to its own thread and can still be mid-`git worktree remove`
|
|
|
|
|
|
// if the user pressed Enter straight after. Removal threads are spawned
|
|
|
|
|
|
// through `spawn_removal`, which marks them `uninterruptible` and lets
|
|
|
|
|
|
// them run to completion; this reaches only speculative work.
|
|
|
|
|
|
worktrunk::shell_exec::cancel_background_commands();
|
|
|
|
|
|
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
// `run_skim` returns Err only on a genuine TUI init / event-loop failure;
|
|
|
|
|
|
// a user cancel is `Ok` with `is_abort` set. Surface a real failure.
|
|
|
|
|
|
let out = output?;
|
|
|
|
|
|
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
// Handle selection
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
if !out.is_abort {
|
2026-03-23 12:18:42 -07:00
|
|
|
|
// Determine action: create (alt-c) or switch (enter)
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// Remove (alt-x) is handled inline in its keybinding callback — it never
|
|
|
|
|
|
// reaches accept.
|
2026-03-05 21:52:29 -08:00
|
|
|
|
let action = match &out.final_event {
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
Event::Action(Action::Accept(Some(label))) if label == "create" => PickerAction::Create,
|
2026-03-05 21:52:29 -08:00
|
|
|
|
_ => PickerAction::Switch,
|
2026-02-22 05:44:49 -08:00
|
|
|
|
};
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
|
2026-03-23 12:18:42 -07:00
|
|
|
|
let should_create = matches!(action, PickerAction::Create);
|
2026-03-22 22:05:21 -07:00
|
|
|
|
|
2026-05-21 19:18:22 -07:00
|
|
|
|
// Get the switch identifier: the query if creating new, otherwise the
|
|
|
|
|
|
// selected item. `picker_item_identifier` yields a worktree path for
|
|
|
|
|
|
// any worktree-backed row and the branch name for a branch-only row
|
|
|
|
|
|
// (same as `wt switch` from CLI) — never the raw `worktree-path:` token.
|
2026-03-23 12:18:42 -07:00
|
|
|
|
let selected = out.selected_items.first();
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
let selected_name = selected.map(|item| picker_item_identifier(item.item.as_ref()));
|
2026-03-23 12:18:42 -07:00
|
|
|
|
let query = out.query.trim().to_string();
|
|
|
|
|
|
let identifier = resolve_identifier(&action, query, selected_name)?;
|
|
|
|
|
|
|
fix(switch): trim redundant Repository rebuilds on the accept path (#3557)
## Summary
Follow-up to #3544's `wt switch` review, from investigating why the
accept path re-forks `git config --list -z` and rebuilds `Repository`
more than necessary.
- Skip the destination-rooted `Repository::at()` in
`spawn_switch_background_hooks` when the approved hook plan is empty
(the common no-project-hooks case) — `HookAnnouncer::flush()` is already
a no-op there, so this changes no behavior, only cost.
- Fix the picker's `is_recovered` accept-path arm, which reused the
startup-time `Repository` unconditionally. An in-picker alt-x/alt-r
during a recovered session can mutate the worktree/branch inventory, and
that arm never rebuilt to see it — the non-recovered arm already gets
this via a fresh `Repository::current()`. Rebuild via `Repository::at`
(mirrors the picker's own `rebuild_repo` idiom) instead of
`Repository::current()`, which fails after a deleted-CWD recovery.
- Replace a misleading comment ("reuse the recovered repo") with the
actual freshness rationale.
A fourth change — sharing the bulk config cache across same-process
`Repository` instances by `git_common_dir` — was implemented and then
reverted: the pre-merge gate's
`test_primary_remote_honours_checkout_default_remote` caught a real
staleness bug (a config mutation between two `Repository::at()` calls,
e.g. from a switch hook, would go unobserved by a cache hit with no
invalidation path). Measured idle-repo cost of the remaining duplicated
config forks is small (~5-10ms each), and the risk of a bespoke
process-wide cache with no write-invalidation wasn't worth it.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` (full test + lint suite): 4483
tests passed
- [x] `cargo clippy --all-targets --features shell-integration-tests --
-D warnings`: clean
- [x] Targeted tests: `git::repository::`, `picker::`,
`worktree::switch::`, `hook_plan::`
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:50:04 -07:00
|
|
|
|
let repo = switch_pipeline_repo(&repo, is_recovered)?;
|
refactor(switch): unify the picker and argument-path switch pipelines (#2858)
## Summary
`wt switch <branch>` and the interactive picker (`wt switch` with no
argument) each ran the same switch sequence as separate, parallel code.
[#2845](https://github.com/max-sixty/worktrunk/pull/2845) made the two
paths *behave* identically; this makes the *code* identical too — a
single `SwitchPipeline` that both entry points build and `.run()`.
`SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the
whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks,
source-identity capture, `plan_switch` → `approve_switch_hooks` →
`validate_switch_templates` → `execute_switch`, output, background
hooks, and `--execute`. Each caller now only resolves a branch
identifier and loads config. The picker-vs-argument differences
(`--execute`, the shell-integration offer, source-identity capture) are
struct field values, not divergent branches.
## Bug fixed
The duplication hid a real bug: the picker passed `yes = true` to
`run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks
without a prompt** — unapproved code from a freshly cloned
`.config/wt.toml` running silently. Every other hook the picker runs
(`post-switch`, `pre-create`, `post-create`) already went through the
approval prompt. With one shared `run_pre_switch_hooks` call gated by
the pipeline's single `verify`/`yes` pair, the picker (which has no
`--yes`) now prompts for project `pre-switch` hooks like `wt switch
<branch>` does — and the two paths can't drift on hook approval again.
## Reviewer notes
- One intentional reorder on the argument path:
`offer_bare_repo_worktree_path_fix` now runs before
`run_pre_switch_hooks` (the picker already used this order). The fix
only mutates `worktree-path` config, which pre-switch hooks never read —
behavior-neutral.
- `capture_switch_source` runs inside `run()` after pre-switch hooks —
the same relative position as the old `run_switch`.
- Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to
private; `worktree/mod.rs` re-exports trimmed accordingly.
## Testing
Pure refactor — the existing `test_switch_*`,
`test_switch_format_json_*`, and `test_switch_picker_*` suites cover
behavior preservation.
`test_switch_picker_pre_switch_hook_requires_approval` is a new
regression test for the bug fix (verified to fail under the bug).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:47:18 -07:00
|
|
|
|
// Clone user config out — `SwitchPipeline` takes `&mut UserConfig` (the
|
|
|
|
|
|
// bare-repo path-fix offer and the shell-integration offer record onto
|
|
|
|
|
|
// it). Project config is loaded on demand inside the pipeline.
|
perf(alias): parallelize prewarm git/config/user-config reads (#2573)
Three commits that compound. Together they cut the alias-dispatch warm
critical path from ~38ms to ~12ms by overlapping the three independent
pre-dispatch reads that previously ran in series.
## What was slow
For `wt <alias>`, the pre-dispatch path had three blocking reads in
sequence:
1. `git rev-parse --git-common-dir ...` (prewarm, ~7ms warm / ~10ms
cold)
2. `git config --list -z` (`all_config`, called via
`project_identifier_warm`, ~10ms warm)
3. `UserConfig::load_with_warnings` (TOML parse of `wt.toml`, ~4ms cold)
Standalone measurement showed `git config --list -z` is dominated by
git's process startup (~4ms of ~5ms total — `git --version` alone is
~4.6ms), and the same applies to the rev-parse fork. Adding a dependency
like `gix-config` would only buy back the subprocess overhead. Running
these reads concurrently buys it without a new dep.
## How it works
`Repository::prewarm_at` runs three sibling threads inside a
`std::thread::scope`:
- `prewarm_rev_parse` — populates `GIT_COMMON_DIR_CACHE` etc. (unchanged
from before)
- `prewarm_git_config` — runs `git config --list -z` and stashes the
parsed `IndexMap` in a new `GIT_CONFIG_PRELOAD` static
- `prewarm_user_config` — calls `UserConfig::load_with_warnings` and
stashes the result in `WORKTRUNK_USER_CONFIG_PRELOAD`
`Repository::at` consumes both preloads into the per-`Repository`
`RepoCache` (`all_config` and `user_config` `OnceCell`s), so the first
access from a `Repository` is a memory hit. On-demand fallbacks remain
in place for tests and any caller that bypasses prewarm.
`LoadedConfigs::load` was a sequential pair of cache reads after this —
both fields became thin cache hits. The third commit inlines it at all 8
callsites and folds the surviving invariants (warning ordering, why
user/project stay distinct) into `Repository::project_config`'s rustdoc.
Net −62 lines.
## Naming
The git/worktrunk-config split is now visible at a glance: `GIT_*` for
git-side data (`GIT_CONFIG_PRELOAD`, `GIT_COMMON_DIR_CACHE`,
`GIT_DIRS`), `WORKTRUNK_*` for worktrunk-side
(`WORKTRUNK_USER_CONFIG_PRELOAD`).
## Numbers
`cargo bench --bench alias`:
- `dispatch/warm/1`: ~13.8ms → ~11.5ms (-17%)
- `dispatch/warm/100`: ~17.4ms → ~12.7ms (-27%)
- `dispatch/cold/1`: ~14.5ms → ~11.6ms (-20%)
`wt-perf timeline -- stub` shows the three sibling spans inside the
prewarm scope starting within ~22µs of each other.
## Behaviour change worth flagging
User-config deprecation warnings (and the "↳ To see details, run `wt
config show`" hint) now emit on every `wt` invocation in a
deprecated-config repo, not just commands that previously called
`repo.user_config()`. Process-wide dedup (`WARNED_DEPRECATED_PATHS` for
warnings, `DEPRECATION_HINT_EMITTED` for the hint) keeps it to once per
process. Behaviour is now uniform across commands; before, `wt config
show` rendered deprecations into stdout but didn't emit them on stderr,
while `wt list` did.
This exposed and removed a duplication in `wt config update`:
`format_update_preview` and the `--print` path were emitting warnings
via `format_deprecation_warnings` directly, which now races prewarm and
prints duplicates. Drop the redundant emission — prewarm is the single
canonical source. Project-config warnings still emit via
`check_project_config`'s separate `check_and_migrate` call.
Minor wart, not blocking: the hint says "to apply updates, run `wt
config update`" even when the user is running that exact command.
Fixable later by reading argv before prewarm or moving the hint into
command handlers.
## Behaviour nuance
`prewarm_git_config` runs `git config --list -z` from `discovery_path`
instead of `git_common_dir` (the rev-parse thread is racing in parallel
and `git_common_dir` isn't known yet). For the default config
(`extensions.worktreeConfig` off — the common case) the output is
byte-identical: linked worktrees and the common dir share one config
file. With `extensions.worktreeConfig` enabled, the new code reads
per-worktree values that the old code masked; that's arguably the
correct behaviour when a user opts into worktree-scoped config, and
worktrunk doesn't itself enable the extension.
## Testing
Standard pre-merge gate (`wt hook pre-merge --yes`) passes — 3464 tests
including config-show snapshot tests with the new stderr lines. 18
snapshot files updated to reflect the warning-uniformity change; net
diff is one or two added stderr lines per test.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 00:04:40 -07:00
|
|
|
|
let mut config = repo.user_config().clone();
|
2026-03-23 12:18:42 -07:00
|
|
|
|
|
refactor(switch): unify the picker and argument-path switch pipelines (#2858)
## Summary
`wt switch <branch>` and the interactive picker (`wt switch` with no
argument) each ran the same switch sequence as separate, parallel code.
[#2845](https://github.com/max-sixty/worktrunk/pull/2845) made the two
paths *behave* identically; this makes the *code* identical too — a
single `SwitchPipeline` that both entry points build and `.run()`.
`SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the
whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks,
source-identity capture, `plan_switch` → `approve_switch_hooks` →
`validate_switch_templates` → `execute_switch`, output, background
hooks, and `--execute`. Each caller now only resolves a branch
identifier and loads config. The picker-vs-argument differences
(`--execute`, the shell-integration offer, source-identity capture) are
struct field values, not divergent branches.
## Bug fixed
The duplication hid a real bug: the picker passed `yes = true` to
`run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks
without a prompt** — unapproved code from a freshly cloned
`.config/wt.toml` running silently. Every other hook the picker runs
(`post-switch`, `pre-create`, `post-create`) already went through the
approval prompt. With one shared `run_pre_switch_hooks` call gated by
the pipeline's single `verify`/`yes` pair, the picker (which has no
`--yes`) now prompts for project `pre-switch` hooks like `wt switch
<branch>` does — and the two paths can't drift on hook approval again.
## Reviewer notes
- One intentional reorder on the argument path:
`offer_bare_repo_worktree_path_fix` now runs before
`run_pre_switch_hooks` (the picker already used this order). The fix
only mutates `worktree-path` config, which pre-switch hooks never read —
behavior-neutral.
- `capture_switch_source` runs inside `run()` after pre-switch hooks —
the same relative position as the old `run_switch`.
- Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to
private; `worktree/mod.rs` re-exports trimmed accordingly.
## Testing
Pure refactor — the existing `test_switch_*`,
`test_switch_format_json_*`, and `test_switch_picker_*` suites cover
behavior preservation.
`test_switch_picker_pre_switch_hook_requires_approval` is a new
regression test for the bug fix (verified to fail under the bug).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:47:18 -07:00
|
|
|
|
// Run the switch — the same `SwitchPipeline` as `wt switch <branch>`,
|
|
|
|
|
|
// so hooks, approval, and output cannot drift from the argument path.
|
2026-07-10 04:30:14 -07:00
|
|
|
|
// The picker offers no shell integration, but (like the argument path)
|
|
|
|
|
|
// the pipeline captures the pre-switch source worktree, so an existing
|
|
|
|
|
|
// switch's `{{ base }}` / `{{ base_worktree_path }}` resolve to the
|
|
|
|
|
|
// worktree the user came from. An `--execute` command (`wt switch -x
|
|
|
|
|
|
// <cmd>`) runs against the picked worktree, and its `{{ base }}` matches
|
|
|
|
|
|
// what `wt switch <branch> -x <cmd>` would produce.
|
refactor(switch): unify the picker and argument-path switch pipelines (#2858)
## Summary
`wt switch <branch>` and the interactive picker (`wt switch` with no
argument) each ran the same switch sequence as separate, parallel code.
[#2845](https://github.com/max-sixty/worktrunk/pull/2845) made the two
paths *behave* identically; this makes the *code* identical too — a
single `SwitchPipeline` that both entry points build and `.run()`.
`SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the
whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks,
source-identity capture, `plan_switch` → `approve_switch_hooks` →
`validate_switch_templates` → `execute_switch`, output, background
hooks, and `--execute`. Each caller now only resolves a branch
identifier and loads config. The picker-vs-argument differences
(`--execute`, the shell-integration offer, source-identity capture) are
struct field values, not divergent branches.
## Bug fixed
The duplication hid a real bug: the picker passed `yes = true` to
`run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks
without a prompt** — unapproved code from a freshly cloned
`.config/wt.toml` running silently. Every other hook the picker runs
(`post-switch`, `pre-create`, `post-create`) already went through the
approval prompt. With one shared `run_pre_switch_hooks` call gated by
the pipeline's single `verify`/`yes` pair, the picker (which has no
`--yes`) now prompts for project `pre-switch` hooks like `wt switch
<branch>` does — and the two paths can't drift on hook approval again.
## Reviewer notes
- One intentional reorder on the argument path:
`offer_bare_repo_worktree_path_fix` now runs before
`run_pre_switch_hooks` (the picker already used this order). The fix
only mutates `worktree-path` config, which pre-switch hooks never read —
behavior-neutral.
- `capture_switch_source` runs inside `run()` after pre-switch hooks —
the same relative position as the old `run_switch`.
- Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to
private; `worktree/mod.rs` re-exports trimmed accordingly.
## Testing
Pure refactor — the existing `test_switch_*`,
`test_switch_format_json_*`, and `test_switch_picker_*` suites cover
behavior preservation.
`test_switch_picker_pre_switch_hook_requires_approval` is a new
regression test for the bug fix (verified to fail under the bug).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:47:18 -07:00
|
|
|
|
SwitchPipeline {
|
|
|
|
|
|
repo: &repo,
|
|
|
|
|
|
config: &mut config,
|
|
|
|
|
|
identifier: &identifier,
|
|
|
|
|
|
create: should_create,
|
|
|
|
|
|
base: None,
|
|
|
|
|
|
clobber: false,
|
|
|
|
|
|
verify: true,
|
|
|
|
|
|
yes: false,
|
|
|
|
|
|
change_dir,
|
|
|
|
|
|
format,
|
|
|
|
|
|
is_recovered,
|
|
|
|
|
|
suggestion_ctx: None,
|
2026-07-10 04:30:14 -07:00
|
|
|
|
execute,
|
|
|
|
|
|
execute_args,
|
refactor(switch): unify the picker and argument-path switch pipelines (#2858)
## Summary
`wt switch <branch>` and the interactive picker (`wt switch` with no
argument) each ran the same switch sequence as separate, parallel code.
[#2845](https://github.com/max-sixty/worktrunk/pull/2845) made the two
paths *behave* identically; this makes the *code* identical too — a
single `SwitchPipeline` that both entry points build and `.run()`.
`SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the
whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks,
source-identity capture, `plan_switch` → `approve_switch_hooks` →
`validate_switch_templates` → `execute_switch`, output, background
hooks, and `--execute`. Each caller now only resolves a branch
identifier and loads config. The picker-vs-argument differences
(`--execute`, the shell-integration offer, source-identity capture) are
struct field values, not divergent branches.
## Bug fixed
The duplication hid a real bug: the picker passed `yes = true` to
`run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks
without a prompt** — unapproved code from a freshly cloned
`.config/wt.toml` running silently. Every other hook the picker runs
(`post-switch`, `pre-create`, `post-create`) already went through the
approval prompt. With one shared `run_pre_switch_hooks` call gated by
the pipeline's single `verify`/`yes` pair, the picker (which has no
`--yes`) now prompts for project `pre-switch` hooks like `wt switch
<branch>` does — and the two paths can't drift on hook approval again.
## Reviewer notes
- One intentional reorder on the argument path:
`offer_bare_repo_worktree_path_fix` now runs before
`run_pre_switch_hooks` (the picker already used this order). The fix
only mutates `worktree-path` config, which pre-switch hooks never read —
behavior-neutral.
- `capture_switch_source` runs inside `run()` after pre-switch hooks —
the same relative position as the old `run_switch`.
- Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to
private; `worktree/mod.rs` re-exports trimmed accordingly.
## Testing
Pure refactor — the existing `test_switch_*`,
`test_switch_format_json_*`, and `test_switch_picker_*` suites cover
behavior preservation.
`test_switch_picker_pre_switch_hook_requires_approval` is a new
regression test for the bug fix (verified to fail under the bug).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:47:18 -07:00
|
|
|
|
shell_integration_binary: None,
|
2026-02-06 10:05:18 -08:00
|
|
|
|
}
|
refactor(switch): unify the picker and argument-path switch pipelines (#2858)
## Summary
`wt switch <branch>` and the interactive picker (`wt switch` with no
argument) each ran the same switch sequence as separate, parallel code.
[#2845](https://github.com/max-sixty/worktrunk/pull/2845) made the two
paths *behave* identically; this makes the *code* identical too — a
single `SwitchPipeline` that both entry points build and `.run()`.
`SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the
whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks,
source-identity capture, `plan_switch` → `approve_switch_hooks` →
`validate_switch_templates` → `execute_switch`, output, background
hooks, and `--execute`. Each caller now only resolves a branch
identifier and loads config. The picker-vs-argument differences
(`--execute`, the shell-integration offer, source-identity capture) are
struct field values, not divergent branches.
## Bug fixed
The duplication hid a real bug: the picker passed `yes = true` to
`run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks
without a prompt** — unapproved code from a freshly cloned
`.config/wt.toml` running silently. Every other hook the picker runs
(`post-switch`, `pre-create`, `post-create`) already went through the
approval prompt. With one shared `run_pre_switch_hooks` call gated by
the pipeline's single `verify`/`yes` pair, the picker (which has no
`--yes`) now prompts for project `pre-switch` hooks like `wt switch
<branch>` does — and the two paths can't drift on hook approval again.
## Reviewer notes
- One intentional reorder on the argument path:
`offer_bare_repo_worktree_path_fix` now runs before
`run_pre_switch_hooks` (the picker already used this order). The fix
only mutates `worktree-path` config, which pre-switch hooks never read —
behavior-neutral.
- `capture_switch_source` runs inside `run()` after pre-switch hooks —
the same relative position as the old `run_switch`.
- Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to
private; `worktree/mod.rs` re-exports trimmed accordingly.
## Testing
Pure refactor — the existing `test_switch_*`,
`test_switch_format_json_*`, and `test_switch_picker_*` suites cover
behavior preservation.
`test_switch_picker_pre_switch_hook_requires_approval` is a new
regression test for the bug fix (verified to fail under the bug).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:47:18 -07:00
|
|
|
|
.run()?;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
/// Install the preview-tab switches into skim's keymap: alt-1…alt-7 jump to a
|
|
|
|
|
|
/// tab, tab / shift-tab cycle forward / backward.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// skim's string bind API only maps keys to its built-in actions, so these go
|
|
|
|
|
|
/// in as `Action::Custom` callbacks that set the process-wide
|
|
|
|
|
|
/// [`PreviewStateData`] mode and return `Event::RunPreview` to repaint. They're
|
|
|
|
|
|
/// native rather than `execute-silent` shell commands, so they behave
|
|
|
|
|
|
/// identically everywhere — the previous `echo`/`tr`/`mv` keybind bodies ran
|
|
|
|
|
|
/// through skim's shell, which on Windows is cmd.exe and has neither `tr` nor
|
|
|
|
|
|
/// `mv`. This is also what lets `wt switch` run its picker on Windows at all.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Keys are resolved with skim's own `parse_key` so they match exactly what its
|
|
|
|
|
|
/// keymap lookup expects (`KeyMap` is keyed by the crossterm `KeyEvent`
|
|
|
|
|
|
/// `parse_key` produces). Shift-Tab is bound under every spelling crossterm
|
|
|
|
|
|
/// might report (`btab` / `shift-btab` / `shift-tab`), mirroring skim's default
|
|
|
|
|
|
/// keymap, so the cycle-back override holds regardless of terminal.
|
|
|
|
|
|
fn install_preview_tab_keybindings(keymap: &mut skim::binds::KeyMap) {
|
|
|
|
|
|
use skim::binds::parse_key;
|
|
|
|
|
|
|
|
|
|
|
|
// alt-N jumps to tab N (1-indexed, matching PreviewMode's discriminant).
|
|
|
|
|
|
let switch_to = |mode: PreviewMode| {
|
|
|
|
|
|
Action::Custom(ActionCallback::new_sync(move |_app| {
|
|
|
|
|
|
PreviewStateData::set_mode(mode);
|
|
|
|
|
|
Ok(vec![Event::RunPreview])
|
|
|
|
|
|
}))
|
|
|
|
|
|
};
|
|
|
|
|
|
for digit in 1..=7u8 {
|
|
|
|
|
|
if let Ok(key) = parse_key(&format!("alt-{digit}")) {
|
|
|
|
|
|
keymap.insert(key, vec![switch_to(PreviewMode::from_u8(digit))]);
|
|
|
|
|
|
}
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
let cycle = |forward: bool| {
|
|
|
|
|
|
Action::Custom(ActionCallback::new_sync(move |_app| {
|
|
|
|
|
|
PreviewStateData::rotate(forward);
|
|
|
|
|
|
Ok(vec![Event::RunPreview])
|
|
|
|
|
|
}))
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Ok(key) = parse_key("tab") {
|
|
|
|
|
|
keymap.insert(key, vec![cycle(true)]);
|
|
|
|
|
|
}
|
|
|
|
|
|
for back in ["btab", "shift-btab", "shift-tab"] {
|
|
|
|
|
|
if let Ok(key) = parse_key(back) {
|
|
|
|
|
|
keymap.insert(key, vec![cycle(false)]);
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// The branch name `alt-y` copies for the row whose `output()` token is `token`:
|
|
|
|
|
|
/// its `RowShortcutData.branch`. `None` when the token isn't in the table or the
|
|
|
|
|
|
/// row has no branch (a detached worktree), so `alt-y` no-ops. Pulled out of the
|
|
|
|
|
|
/// keybinding closure so the lookup — the part that doesn't need a live skim
|
|
|
|
|
|
/// `App` — is unit-testable.
|
|
|
|
|
|
fn resolve_shortcut_branch(table: &ShortcutTable, token: &str) -> Option<String> {
|
|
|
|
|
|
table
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.get(token)
|
|
|
|
|
|
.and_then(|d| d.branch.clone())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The PR/MR URL `alt-o` opens for the row whose `output()` token is `token`.
|
|
|
|
|
|
/// `None` when the token isn't in the table or the row has no URL (a worktree
|
|
|
|
|
|
/// whose PR hasn't resolved, or has none), so `alt-o` no-ops. The counterpart to
|
|
|
|
|
|
/// [`resolve_shortcut_branch`].
|
|
|
|
|
|
fn resolve_shortcut_url(table: &ShortcutTable, token: &str) -> Option<String> {
|
|
|
|
|
|
table
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.get(token)
|
|
|
|
|
|
.and_then(|d| d.url.resolve())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Install the `alt-y` (copy branch) and `alt-o` (open PR/MR URL) row shortcuts
|
|
|
|
|
|
/// as native callbacks, alongside the preview-tab keys.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Both read the selected row off skim's `App` — its `output()` token, looked up
|
|
|
|
|
|
/// in `shortcut_table` for the branch / URL — and run the OS action (clipboard,
|
|
|
|
|
|
/// browser) on a background thread, so skim's event loop never blocks and a slow
|
|
|
|
|
|
/// clipboard or opener can't freeze the frame. Neither touches the list, so
|
|
|
|
|
|
/// there's no reload and the cursor stays put. Both no-op when the row lacks the
|
|
|
|
|
|
/// thing they act on: `alt-y` on a detached worktree (no branch), `alt-o` on a
|
|
|
|
|
|
/// row with no URL (a worktree whose PR hasn't resolved, or has none). Failures
|
|
|
|
|
|
/// are logged, not surfaced — skim owns the terminal.
|
|
|
|
|
|
fn install_shortcut_keybindings(keymap: &mut skim::binds::KeyMap, shortcut_table: ShortcutTable) {
|
|
|
|
|
|
use skim::binds::parse_key;
|
|
|
|
|
|
|
|
|
|
|
|
// alt-y: copy the selected row's branch name to the system clipboard.
|
|
|
|
|
|
if let Ok(key) = parse_key("alt-y") {
|
|
|
|
|
|
let table = Arc::clone(&shortcut_table);
|
|
|
|
|
|
keymap.insert(
|
|
|
|
|
|
key,
|
|
|
|
|
|
vec![Action::Custom(ActionCallback::new_sync(move |app| {
|
|
|
|
|
|
let branch = app
|
|
|
|
|
|
.item_list
|
|
|
|
|
|
.selected()
|
|
|
|
|
|
.and_then(|m| resolve_shortcut_branch(&table, m.item.output().as_ref()));
|
|
|
|
|
|
if let Some(branch) = branch {
|
|
|
|
|
|
spawn_shortcut("picker-copy", move || os::copy_to_clipboard(&branch));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(Vec::new())
|
|
|
|
|
|
}))],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// alt-o: open the selected row's PR/MR URL in the browser.
|
|
|
|
|
|
if let Ok(key) = parse_key("alt-o") {
|
|
|
|
|
|
let table = Arc::clone(&shortcut_table);
|
|
|
|
|
|
keymap.insert(
|
|
|
|
|
|
key,
|
|
|
|
|
|
vec![Action::Custom(ActionCallback::new_sync(move |app| {
|
|
|
|
|
|
let url = app
|
|
|
|
|
|
.item_list
|
|
|
|
|
|
.selected()
|
|
|
|
|
|
.and_then(|m| resolve_shortcut_url(&table, m.item.output().as_ref()));
|
|
|
|
|
|
if let Some(url) = url {
|
|
|
|
|
|
spawn_shortcut("picker-open", move || os::open_url(&url));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(Vec::new())
|
|
|
|
|
|
}))],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Install `alt-x` (remove the selected row) as a native binding: a single Custom
|
|
|
|
|
|
/// callback that runs the removal synchronously through [`AltXRemover`].
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// `alt-x` no longer goes through skim's `reload`. A `reload` clears the item pool
|
|
|
|
|
|
/// and runs the matcher against it once *before* the new rows arrive, which resets
|
|
|
|
|
|
/// the cursor to the top (`current = 0`) for a frame — the flash this fixes. Here
|
|
|
|
|
|
/// the callback mutates the row list ([`AltXRemover::apply`]) and rebuilds skim's
|
|
|
|
|
|
/// pool itself ([`resync_pool`]) on the same event-loop tick, so the matcher only
|
|
|
|
|
|
/// ever sees the post-removal list and the cursor holds its slot. The
|
|
|
|
|
|
/// [`RemovalEffect`] says how to refresh skim's view: a drop resyncs the pool, a
|
|
|
|
|
|
/// morph repaints the row in place and refreshes its (now-dimmed) preview, a kept
|
|
|
|
|
|
/// row needs nothing.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
///
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// The callback owns the `remover` (moved in) — skim requires a `Send` callback,
|
|
|
|
|
|
/// which is why [`AltXRemover`] carries only `Send` state and not the collector's
|
|
|
|
|
|
/// `Rc<PipelineFactory>`. A native keymap insert (not a string bind) is required
|
|
|
|
|
|
/// because a string bind can't express a Rust callback (like the preview-tab and
|
|
|
|
|
|
/// row shortcuts).
|
|
|
|
|
|
fn install_remove_keybinding(keymap: &mut skim::binds::KeyMap, remover: AltXRemover) {
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use skim::binds::parse_key;
|
|
|
|
|
|
let Ok(key) = parse_key("alt-x") else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let cb = Action::Custom(ActionCallback::new_sync(move |app| {
|
|
|
|
|
|
// The selected row's `output()` token identifies what to remove. No
|
|
|
|
|
|
// selection (empty list) → nothing to do.
|
|
|
|
|
|
let Some(selected) = app.item_list.selected() else {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
};
|
|
|
|
|
|
let selected_output = selected.item.output().into_owned();
|
|
|
|
|
|
match remover.apply(selected_output) {
|
|
|
|
|
|
RemovalEffect::Dropped => {
|
|
|
|
|
|
// The row left `items`; rebuild skim's pool from the shrunk list so
|
|
|
|
|
|
// the matcher re-filters it in place — the cursor holds its index and
|
|
|
|
|
|
// the row that slid up lands under it (no reset, no flash). skim
|
|
|
|
|
|
// processes a callback's returned events (then a Render) in order, so
|
|
|
|
|
|
// the queued resync runs before any repaint — same effect as an inline
|
|
|
|
|
|
// rebuild, and it shares the one `resync_pool_action` the failed-removal
|
|
|
|
|
|
// restore also queues. Then a settled-gated `RunPreview`: for a
|
|
|
|
|
|
// *last*-row drop skim's own preview-on-selection-change can't fire
|
|
|
|
|
|
// (`current` goes briefly out of range), so the pane would otherwise
|
|
|
|
|
|
// keep showing the removed row; for a middle-row drop skim already
|
|
|
|
|
|
// refreshes it, so this is a cheap cache-hit repaint.
|
|
|
|
|
|
Ok(vec![
|
|
|
|
|
|
Event::Action(resync_pool_action(Arc::clone(&remover.items))),
|
|
|
|
|
|
Event::Action(run_preview_when_settled(
|
|
|
|
|
|
Arc::new(AtomicUsize::new(0)),
|
|
|
|
|
|
Arc::new(AtomicUsize::new(0)),
|
|
|
|
|
|
)),
|
|
|
|
|
|
])
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
}
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// The row's content changed in place (same item, no `Replace`), so the
|
|
|
|
|
|
// cursor doesn't move and skim's auto-preview doesn't fire — repaint the
|
|
|
|
|
|
// list row and request its (now working-tree-dimmed) preview explicitly.
|
|
|
|
|
|
RemovalEffect::Morphed => Ok(vec![Event::Render, Event::RunPreview]),
|
|
|
|
|
|
// The row is unchanged (declined / retained, with a stashed hint shown
|
|
|
|
|
|
// on exit); the cursor never moved, so nothing to repaint.
|
|
|
|
|
|
RemovalEffect::Kept => Ok(Vec::new()),
|
|
|
|
|
|
}
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
}));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
keymap.insert(key, vec![cb]);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
fix(picker): cancel background commands when the picker exits (#3560)
## What
`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.
Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.
Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.
## Why
Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.
Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:
| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |
Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).
## Why a latch, not just a sweep
A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.
Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.
This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.
## Notes
- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
the picker stays live, so pressing Enter straight after can leave a
`git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
each call site.
- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
test is deterministic (a file marker for readiness, not a sleep).
An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.
Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.
Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
are pre-existing: the `spawn_removal` refactor re-indents the dispatch
closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
judged worth more than the metric.
- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00
|
|
|
|
/// Run a removal's git work on a named background thread, exempt from
|
|
|
|
|
|
/// background-command cancellation: a removal is work the user asked for, and
|
|
|
|
|
|
/// a SIGTERM landing between `git worktree remove` and the branch delete would
|
|
|
|
|
|
/// leave them half-removed. Every removal dispatch goes through here so the
|
|
|
|
|
|
/// exemption is carried by the spawn path itself rather than remembered at
|
|
|
|
|
|
/// each call site.
|
|
|
|
|
|
fn spawn_removal<F>(name: String, work: F)
|
|
|
|
|
|
where
|
|
|
|
|
|
F: FnOnce() + Send + 'static,
|
|
|
|
|
|
{
|
|
|
|
|
|
let _ = std::thread::Builder::new()
|
|
|
|
|
|
.name(name)
|
|
|
|
|
|
.spawn(move || worktrunk::shell_exec::uninterruptible(work));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// Run a row shortcut's OS action on a named background thread, logging any
|
|
|
|
|
|
/// failure — the picker owns the terminal, so an error can't be shown inline.
|
|
|
|
|
|
fn spawn_shortcut<F>(name: &str, action: F)
|
|
|
|
|
|
where
|
|
|
|
|
|
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
|
|
|
|
|
|
{
|
|
|
|
|
|
let _ = std::thread::Builder::new()
|
|
|
|
|
|
.name(name.to_string())
|
|
|
|
|
|
.spawn(move || {
|
|
|
|
|
|
if let Err(e) = action() {
|
|
|
|
|
|
log::warn!("picker: {e:#}");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
/// Run skim to completion, exposing its event sender for progressive repaints.
|
|
|
|
|
|
///
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
/// This inlines what `Skim::run_with` does, plus one addition: after the TUI is
|
|
|
|
|
|
/// initialized we publish `Skim::event_sender()` into `render_tx`. skim 4.x
|
|
|
|
|
|
/// renders on demand, so the background collect thread's in-place row mutations
|
|
|
|
|
|
/// stay invisible until something wakes the event loop — the handler pushes
|
|
|
|
|
|
/// `Event::Render` through that sender (see `progressive_handler`), and the
|
|
|
|
|
|
/// preview-tab keybindings return `Event::RunPreview` from their callbacks.
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
///
|
|
|
|
|
|
/// `wt` runs no outer tokio runtime, so skim's event loop runs on a fresh
|
|
|
|
|
|
/// multi-thread `Runtime` — the same one `run_with` builds in that case. A user
|
|
|
|
|
|
/// cancel is `Ok(SkimOutput)` with `is_abort` set; only a genuine init /
|
|
|
|
|
|
/// event-loop failure is an `Err`.
|
|
|
|
|
|
///
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
/// Injecting `Event::Render` / `Event::RunPreview` is safe against clobbering
|
|
|
|
|
|
/// the recorded selection: skim's `Accept` / `Abort` set `should_quit` in the
|
|
|
|
|
|
/// same `tick` that records them as `final_event`, and `run()` breaks before
|
|
|
|
|
|
/// the next `tick`, so a trailing injected event is never processed after the
|
|
|
|
|
|
/// terminal action.
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
fn run_skim(
|
|
|
|
|
|
options: SkimOptions,
|
|
|
|
|
|
rx: SkimItemReceiver,
|
|
|
|
|
|
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
|
|
|
|
|
|
) -> anyhow::Result<SkimOutput> {
|
|
|
|
|
|
let mut skim: Skim = Skim::init(options, Some(rx))
|
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("failed to initialize picker: {e}"))?;
|
|
|
|
|
|
skim.start();
|
|
|
|
|
|
|
|
|
|
|
|
// `should_enter` is false only for skim's filter / select-1 / exit-0 / sync
|
|
|
|
|
|
// modes — none of which the picker enables — so the TUI is always entered.
|
|
|
|
|
|
// The guard just keeps the fallback safe (an aborted output) rather than
|
|
|
|
|
|
// panicking on `event_sender()` if that ever changes.
|
|
|
|
|
|
if skim.should_enter() {
|
|
|
|
|
|
skim.init_tui()
|
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("failed to initialize picker TUI: {e}"))?;
|
|
|
|
|
|
// event_sender() requires init_tui(); publish it before entering the
|
|
|
|
|
|
// loop so the handler's in-place updates can request repaints.
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
let _ = render_tx.set(skim.event_sender());
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
|
|
|
|
|
|
let runtime =
|
|
|
|
|
|
tokio::runtime::Runtime::new().context("failed to start picker event-loop runtime")?;
|
|
|
|
|
|
let result = runtime.block_on(async {
|
|
|
|
|
|
skim.enter().await?;
|
|
|
|
|
|
skim.run().await
|
|
|
|
|
|
});
|
|
|
|
|
|
result.map_err(|e| anyhow::anyhow!("interactive picker failed: {e}"))?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(skim.output())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 09:12:01 -07:00
|
|
|
|
/// Resolve the branch identifier from picker output.
|
2026-03-11 22:12:16 -07:00
|
|
|
|
///
|
2026-05-20 21:05:19 -07:00
|
|
|
|
/// Extracted from the picker's accept handler for testability.
|
2026-03-16 09:12:01 -07:00
|
|
|
|
fn resolve_identifier(
|
2026-03-11 22:12:16 -07:00
|
|
|
|
action: &PickerAction,
|
|
|
|
|
|
query: String,
|
|
|
|
|
|
selected_name: Option<String>,
|
|
|
|
|
|
) -> anyhow::Result<String> {
|
|
|
|
|
|
match action {
|
|
|
|
|
|
PickerAction::Create => {
|
|
|
|
|
|
if query.is_empty() {
|
|
|
|
|
|
anyhow::bail!("Cannot create worktree: no branch name entered");
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(query)
|
|
|
|
|
|
}
|
2026-03-16 09:12:01 -07:00
|
|
|
|
PickerAction::Switch => match selected_name {
|
|
|
|
|
|
Some(name) => Ok(name),
|
|
|
|
|
|
None => {
|
|
|
|
|
|
if query.is_empty() {
|
|
|
|
|
|
anyhow::bail!("No worktree selected");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
anyhow::bail!(
|
|
|
|
|
|
"No worktree matches '{query}' — use alt-c to create a new worktree"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-11 22:12:16 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): trim redundant Repository rebuilds on the accept path (#3557)
## Summary
Follow-up to #3544's `wt switch` review, from investigating why the
accept path re-forks `git config --list -z` and rebuilds `Repository`
more than necessary.
- Skip the destination-rooted `Repository::at()` in
`spawn_switch_background_hooks` when the approved hook plan is empty
(the common no-project-hooks case) — `HookAnnouncer::flush()` is already
a no-op there, so this changes no behavior, only cost.
- Fix the picker's `is_recovered` accept-path arm, which reused the
startup-time `Repository` unconditionally. An in-picker alt-x/alt-r
during a recovered session can mutate the worktree/branch inventory, and
that arm never rebuilt to see it — the non-recovered arm already gets
this via a fresh `Repository::current()`. Rebuild via `Repository::at`
(mirrors the picker's own `rebuild_repo` idiom) instead of
`Repository::current()`, which fails after a deleted-CWD recovery.
- Replace a misleading comment ("reuse the recovered repo") with the
actual freshness rationale.
A fourth change — sharing the bulk config cache across same-process
`Repository` instances by `git_common_dir` — was implemented and then
reverted: the pre-merge gate's
`test_primary_remote_honours_checkout_default_remote` caught a real
staleness bug (a config mutation between two `Repository::at()` calls,
e.g. from a switch hook, would go unobserved by a cache hit with no
invalidation path). Measured idle-repo cost of the remaining duplicated
config forks is small (~5-10ms each), and the risk of a bespoke
process-wide cache with no write-invalidation wasn't worth it.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` (full test + lint suite): 4483
tests passed
- [x] `cargo clippy --all-targets --features shell-integration-tests --
-D warnings`: clean
- [x] Targeted tests: `git::repository::`, `picker::`,
`worktree::switch::`, `hook_plan::`
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:50:04 -07:00
|
|
|
|
/// Select the `Repository` the accept path's `SwitchPipeline` runs against.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Extracted from the picker's accept handler for testability.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Rebuilds fresh rather than reuse `repo` (whose `OnceCell` worktree/branch
|
|
|
|
|
|
/// caches were primed at picker startup and never invalidated, same
|
|
|
|
|
|
/// discipline `spawn`'s `rebuild_repo` doc explains): an in-picker alt-x/alt-r
|
|
|
|
|
|
/// during this session can have removed or added worktrees/branches since.
|
|
|
|
|
|
/// `Repository::current()` re-discovers from cwd, which fails after a
|
|
|
|
|
|
/// deleted-CWD recovery, so the recovered arm rebuilds via `Repository::at`
|
|
|
|
|
|
/// on the already-recovered discovery path instead of reusing the stale
|
|
|
|
|
|
/// startup snapshot.
|
|
|
|
|
|
fn switch_pipeline_repo(repo: &Repository, is_recovered: bool) -> anyhow::Result<Repository> {
|
|
|
|
|
|
if is_recovered {
|
|
|
|
|
|
Repository::at(repo.discovery_path()).context("Failed to switch worktree")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Repository::current().context("Failed to switch worktree")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
pub mod tests {
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
use super::items::{LocalCheckout, LocalContent, PickerRow, worktree_output_token};
|
2026-05-21 19:18:22 -07:00
|
|
|
|
use super::{
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
AltXRemover, PickerAction, PickerRemovalTarget, RemovalEffect, drain_stashed_warnings,
|
|
|
|
|
|
install_preview_tab_keybindings, install_shortcut_keybindings, picker_item_identifier,
|
fix(switch): trim redundant Repository rebuilds on the accept path (#3557)
## Summary
Follow-up to #3544's `wt switch` review, from investigating why the
accept path re-forks `git config --list -z` and rebuilds `Repository`
more than necessary.
- Skip the destination-rooted `Repository::at()` in
`spawn_switch_background_hooks` when the approved hook plan is empty
(the common no-project-hooks case) — `HookAnnouncer::flush()` is already
a no-op there, so this changes no behavior, only cost.
- Fix the picker's `is_recovered` accept-path arm, which reused the
startup-time `Repository` unconditionally. An in-picker alt-x/alt-r
during a recovered session can mutate the worktree/branch inventory, and
that arm never rebuilt to see it — the non-recovered arm already gets
this via a fresh `Repository::current()`. Rebuild via `Repository::at`
(mirrors the picker's own `rebuild_repo` idiom) instead of
`Repository::current()`, which fails after a deleted-CWD recovery.
- Replace a misleading comment ("reuse the recovered repo") with the
actual freshness rationale.
A fourth change — sharing the bulk config cache across same-process
`Repository` instances by `git_common_dir` — was implemented and then
reverted: the pre-merge gate's
`test_primary_remote_honours_checkout_default_remote` caught a real
staleness bug (a config mutation between two `Repository::at()` calls,
e.g. from a switch hook, would go unobserved by a cache hit with no
invalidation path). Measured idle-repo cost of the remaining duplicated
config forks is small (~5-10ms each), and the risk of a bespoke
process-wide cache with no write-invalidation wasn't worth it.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` (full test + lint suite): 4483
tests passed
- [x] `cargo clippy --all-targets --features shell-integration-tests --
-D warnings`: clean
- [x] Targeted tests: `git::repository::`, `picker::`,
`worktree::switch::`, `hook_plan::`
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:50:04 -07:00
|
|
|
|
resolve_identifier, resolve_shortcut_branch, resolve_shortcut_url, switch_pipeline_repo,
|
2026-05-21 19:18:22 -07:00
|
|
|
|
};
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use crate::commands::list::model::{BranchScope, ItemKind, ListItem, WorktreeData};
|
Expose worktree removal API from the library crate (#2227)
Requested by @max-sixty in max-sixty/worktrunk#2053 so `worktrunk-sync`
(https://github.com/pablospe/worktrunk-sync) can reuse the canonical
removal flow instead of reimplementing it with raw git commands (missing
fsmonitor cleanup and trash-path staging).
## Summary
Move the worktree-removal logic out of the binary-only
`commands::worktree` module into the library crate. External tools can
now call:
```rust
use worktrunk::git::{BranchDeletionMode, RemoveOptions, remove_worktree_with_cleanup};
let output = remove_worktree_with_cleanup(
&repo,
worktree_path,
RemoveOptions {
branch: Some(branch_name.into()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".into()),
force_worktree: false,
},
)?;
```
to get the same semantics as `wt remove` / `wt merge --remove` —
fsmonitor daemon stopped before removal, fast-path rename into
`<git-common-dir>/wt/trash/`, fallback to `git worktree remove`, then an
optional integration-check-gated branch deletion.
### What's new in `worktrunk::git`
- `remove_worktree_with_cleanup(repo, path, options) ->
Result<RemovalOutput>` — the high-level entry point.
- `RemoveOptions { branch, deletion_mode, target_branch, force_worktree
}` with `Default` (`SafeDelete`, no branch).
- `RemovalOutput { branch_result, staged_path }` — caller owns the
staged trash entry (e.g. to clean it up in a background process, which
is what `wt remove` does).
- `BranchDeletionMode` enum (`Keep`, `SafeDelete`, `ForceDelete`)
replacing a two-boolean flag pair; exposes `from_flags(keep, force)` for
CLI callers.
- `BranchDeletionOutcome`, `BranchDeletionResult` — exposed so callers
can decide how to surface "not integrated" vs `branch -D` failure.
- Two lower-level helpers for callers that want more control:
`stage_worktree_removal(repo, path) -> Option<PathBuf>` and
`delete_branch_if_safe(repo, branch, target, force)`.
### What moved
- `src/commands/branch_deletion.rs` → folded into `src/git/remove.rs`
(new module).
- `execute_removal` (previously in `commands::worktree`) →
`remove_worktree_with_cleanup` in `worktrunk::git::remove`.
- `generate_removing_path` (previously in `commands::process`) →
`src/git/remove.rs` (still `pub(crate)` since it's an implementation
detail).
- `BranchDeletionMode` (previously duplicated between
`commands::branch_deletion` and `commands::worktree::types`) → single
definition in `src/git/remove.rs`.
All internal callers (`wt remove`, `wt merge --remove`, the TUI picker)
now go through the library module — no duplicate definitions remain.
## Design options for future refactors
This is explicitly not a commitment to the final API shape. Options we
could revisit:
1. **Method on `Repository` vs free function.** Currently
`remove_worktree_with_cleanup(repo, path, options)`. Could become
`repo.remove_worktree_full(path, options)` to match
`repo.remove_worktree(...)`, `repo.prune_worktrees()`, etc. Free
function is closer to the old internal name and avoids adding another
method to the already-large `Repository` impl; a method would be more
discoverable.
2. **`Option<String>` vs `Option<&str>` + lifetime.** `RemoveOptions`
owns its strings to keep the struct `'static` and `Default`-able. We
could drop to borrowed `&str` to save allocations, but it forces a
lifetime on every caller and complicates deserialization from config
values. The current shape is closer to how external tools will actually
build options.
3. **`#[non_exhaustive]` on `RemoveOptions` / `BranchDeletionMode`.**
Not applied yet — using struct literal syntax means adding a new field
is breaking today. If we want the freedom to add knobs (e.g.
`prune_reflog: bool`, `dry_run: bool`) we should mark `RemoveOptions`
non-exhaustive and push callers toward the `..Default::default()`
pattern, or provide a builder. Not done here because zero-field structs
make the non-exhaustive attribute awkward and the module docs already
show `..Default::default()` usage.
4. **Builder vs plain struct.**
`RemoveOptions::new(branch).force_worktree().keep_branch()` would read
more naturally than a struct literal, especially as we add fields. The
struct is simpler and plays better with `serde` if we ever want to drive
this from a config blob.
5. **Split success/failure in `RemovalOutput`.** Today `branch_result:
Option<anyhow::Result<BranchDeletionResult>>` has two `None` meanings
(no branch, or `Keep` mode) and forces callers to unwrap the nested
`Result`. A flatter shape — e.g. an `enum BranchHandling { Skipped,
Kept, Attempted(Result<BranchDeletionResult>) }` — would be more
explicit. Left as-is because the current shape matches how internal
callers already handle the three cases.
6. **`BranchDeletionMode::from_flags(keep, force)` is CLI-flavored.** It
encodes the precedence rule we use in `wt remove`. External tools that
don't have those two flags will construct the enum directly, so the
helper is strictly additive — but if it's the only thing a library
caller sees, it can feel surprising. Option: move `from_flags` to a
`cli` extension module so the library surface is purely semantic.
7. **Exposing `stage_worktree_removal` and `delete_branch_if_safe`
separately.** Useful for callers that want to stage now and background
the `rm -rf`, or do their own integration check. Adds two more public
symbols to maintain. We could collapse them behind
`remove_worktree_with_cleanup` and add a `defer_cleanup: bool` option
instead — simpler API, but less composable.
8. **Who cleans up the trash entry.** `remove_worktree_with_cleanup`
returns `staged_path` and asks the caller to remove it (sync or async).
Alternative: always spawn a detached `rm -rf` internally, like `wt
remove` does. Current choice favours library-friendliness (the binary's
background-cleanup helper isn't something `worktrunk-sync` should take a
dependency on), at the cost of a small amount of extra code in every
caller.
9. **Direct error return vs captured `branch_result`.** Branch-deletion
errors are captured in `branch_result` rather than propagated; worktree
removal is treated as the primary operation. Alternative: fail-fast on
`branch -D` errors so callers don't have to inspect the result. Kept the
current shape because the TUI picker and `worktrunk-sync --prune` both
want to continue the loop regardless of a single branch's deletion
failure.
Happy to take the refactor in whichever direction you prefer before
merging — none of this is locked in.
## Follow-up PR in worktrunk-sync
I'll open a PR against https://github.com/pablospe/worktrunk-sync
swapping the raw-git-command prune path for this new interface, once
this PR's shape is settled (otherwise the downstream PR would need to
change with it).
## Test plan
- [x] `cargo run -- hook pre-merge --yes` passes locally (fmt, clippy
with `-D warnings`, 573 unit tests, 1471 integration tests)
- [x] No behaviour change for `wt remove`, `wt merge --remove`, or the
TUI picker — all three go through the library entry point with the same
semantics as before
- [ ] CI green on Linux / macOS / Windows
- [ ] `codecov/patch` passes — new tests live in `src/git/remove.rs`;
the module exercised the same code paths via integration tests before
the move
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:43:51 -07:00
|
|
|
|
use crate::commands::worktree::RemoveResult;
|
2026-05-21 19:18:22 -07:00
|
|
|
|
use skim::prelude::SkimItem;
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
use std::fs;
|
2026-05-21 19:18:22 -07:00
|
|
|
|
use std::path::Path;
|
feat(switch): keep the alt-r picker cursor on the removed row's slot (#3199)
## Sticky cursor after `alt-r` removal in the switch picker
Removing a worktree with `alt-r` in the `wt switch` picker used to snap
the cursor back to the first row every time, because skim clears
`item_list` on every `reload` (skim #1695). Removing several rows in a
row was jarring — the selection jumped to the top after each one. Now
the cursor stays on the slot the removed row vacated: the row that
slides up into its place (the "next" item), or the new last row when the
removed row was last.
## Why it's done this way
skim 4.8 offers no clean lever for "keep the cursor after reload":
- `handle_reload` calls `item_list.clear()` (resets the cursor to the
top) unless `no_clear_if_empty` is set — and that flag is the wrong
tool: the matcher runs once on the just-cleared empty pool and writes an
empty `Replace`, which re-empties the list and resets the cursor anyway.
Its stale-keeping path is also gated on `interactive` mode, which the
picker isn't.
- `select-row(n)` looks promising but only inserts into the multi-select
set; it never moves the cursor.
- `down(n)` / `first` / `last` take a fixed integer parsed at bind-time,
so the bind string can't carry the dynamic pre-removal index.
The lever that does work is `Action::Custom(ActionCallback)`: its
callback runs with `&mut App`, and `App.item_list` exposes public cursor
methods. After a removal, `PickerCollector::invoke` injects a Custom
action (through skim's event sender — the same `render_tx` the
progressive handler already uses) that, once the reloaded rows land,
repositions via `jump_to_first()` + `scroll_by(target)`. Because the
reload repopulates `item_list` asynchronously (reader → matcher →
render), the action re-arms itself until the rows exist, and stops once
the matcher has *settled* on an empty result so removing the sole match
of an active query can't spin the event loop. Sleeping inside the
callback isn't an option — `ActionCallback::call` blocks on the future,
so an await would hold `&mut App` and starve the very render that loads
the rows.
## Where to look
Everything is in `src/commands/picker/mod.rs`:
- `sticky_reposition_target` — pure index math (removed `shared_items`
position → `item_list` data-row index), unit-tested.
- `reposition_cursor_action` — the self-re-arming `Action::Custom`,
gated on `item_list.count()` with a matcher-settled stop and a hard
backstop.
- `PickerCollector::invoke` — computes the target and injects the
action.
- The `PickerCollector` / module docstrings explain the skim mechanics.
## Limitations
Under an active fuzzy query the displayed order diverges from
`shared_items` order, so the landing row is approximate — a valid nearby
row, clamped into range, rather than the exact next row. The no-query
case (the common one) is exact.
## Testing
Unit test covers the index math (including the removed-last-row and
header-only edge cases). Behavior was verified interactively against a
multi-worktree repo via tmux: middle-row removal lands on the next row,
last-row removal lands on the new last row, a sequence of removals from
one position keeps the cursor planted, rapid-fire removals never reset
to the top, and removing the sole match of a query leaves the picker
fully responsive (no spin). Full TUI behavior isn't unit-testable
without a PTY, so that surface relies on the interactive checks.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:25:32 -07:00
|
|
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
2026-05-21 19:18:22 -07:00
|
|
|
|
use std::time::{Duration, Instant};
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
use worktrunk::config::Approvals;
|
Expose worktree removal API from the library crate (#2227)
Requested by @max-sixty in max-sixty/worktrunk#2053 so `worktrunk-sync`
(https://github.com/pablospe/worktrunk-sync) can reuse the canonical
removal flow instead of reimplementing it with raw git commands (missing
fsmonitor cleanup and trash-path staging).
## Summary
Move the worktree-removal logic out of the binary-only
`commands::worktree` module into the library crate. External tools can
now call:
```rust
use worktrunk::git::{BranchDeletionMode, RemoveOptions, remove_worktree_with_cleanup};
let output = remove_worktree_with_cleanup(
&repo,
worktree_path,
RemoveOptions {
branch: Some(branch_name.into()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".into()),
force_worktree: false,
},
)?;
```
to get the same semantics as `wt remove` / `wt merge --remove` —
fsmonitor daemon stopped before removal, fast-path rename into
`<git-common-dir>/wt/trash/`, fallback to `git worktree remove`, then an
optional integration-check-gated branch deletion.
### What's new in `worktrunk::git`
- `remove_worktree_with_cleanup(repo, path, options) ->
Result<RemovalOutput>` — the high-level entry point.
- `RemoveOptions { branch, deletion_mode, target_branch, force_worktree
}` with `Default` (`SafeDelete`, no branch).
- `RemovalOutput { branch_result, staged_path }` — caller owns the
staged trash entry (e.g. to clean it up in a background process, which
is what `wt remove` does).
- `BranchDeletionMode` enum (`Keep`, `SafeDelete`, `ForceDelete`)
replacing a two-boolean flag pair; exposes `from_flags(keep, force)` for
CLI callers.
- `BranchDeletionOutcome`, `BranchDeletionResult` — exposed so callers
can decide how to surface "not integrated" vs `branch -D` failure.
- Two lower-level helpers for callers that want more control:
`stage_worktree_removal(repo, path) -> Option<PathBuf>` and
`delete_branch_if_safe(repo, branch, target, force)`.
### What moved
- `src/commands/branch_deletion.rs` → folded into `src/git/remove.rs`
(new module).
- `execute_removal` (previously in `commands::worktree`) →
`remove_worktree_with_cleanup` in `worktrunk::git::remove`.
- `generate_removing_path` (previously in `commands::process`) →
`src/git/remove.rs` (still `pub(crate)` since it's an implementation
detail).
- `BranchDeletionMode` (previously duplicated between
`commands::branch_deletion` and `commands::worktree::types`) → single
definition in `src/git/remove.rs`.
All internal callers (`wt remove`, `wt merge --remove`, the TUI picker)
now go through the library module — no duplicate definitions remain.
## Design options for future refactors
This is explicitly not a commitment to the final API shape. Options we
could revisit:
1. **Method on `Repository` vs free function.** Currently
`remove_worktree_with_cleanup(repo, path, options)`. Could become
`repo.remove_worktree_full(path, options)` to match
`repo.remove_worktree(...)`, `repo.prune_worktrees()`, etc. Free
function is closer to the old internal name and avoids adding another
method to the already-large `Repository` impl; a method would be more
discoverable.
2. **`Option<String>` vs `Option<&str>` + lifetime.** `RemoveOptions`
owns its strings to keep the struct `'static` and `Default`-able. We
could drop to borrowed `&str` to save allocations, but it forces a
lifetime on every caller and complicates deserialization from config
values. The current shape is closer to how external tools will actually
build options.
3. **`#[non_exhaustive]` on `RemoveOptions` / `BranchDeletionMode`.**
Not applied yet — using struct literal syntax means adding a new field
is breaking today. If we want the freedom to add knobs (e.g.
`prune_reflog: bool`, `dry_run: bool`) we should mark `RemoveOptions`
non-exhaustive and push callers toward the `..Default::default()`
pattern, or provide a builder. Not done here because zero-field structs
make the non-exhaustive attribute awkward and the module docs already
show `..Default::default()` usage.
4. **Builder vs plain struct.**
`RemoveOptions::new(branch).force_worktree().keep_branch()` would read
more naturally than a struct literal, especially as we add fields. The
struct is simpler and plays better with `serde` if we ever want to drive
this from a config blob.
5. **Split success/failure in `RemovalOutput`.** Today `branch_result:
Option<anyhow::Result<BranchDeletionResult>>` has two `None` meanings
(no branch, or `Keep` mode) and forces callers to unwrap the nested
`Result`. A flatter shape — e.g. an `enum BranchHandling { Skipped,
Kept, Attempted(Result<BranchDeletionResult>) }` — would be more
explicit. Left as-is because the current shape matches how internal
callers already handle the three cases.
6. **`BranchDeletionMode::from_flags(keep, force)` is CLI-flavored.** It
encodes the precedence rule we use in `wt remove`. External tools that
don't have those two flags will construct the enum directly, so the
helper is strictly additive — but if it's the only thing a library
caller sees, it can feel surprising. Option: move `from_flags` to a
`cli` extension module so the library surface is purely semantic.
7. **Exposing `stage_worktree_removal` and `delete_branch_if_safe`
separately.** Useful for callers that want to stage now and background
the `rm -rf`, or do their own integration check. Adds two more public
symbols to maintain. We could collapse them behind
`remove_worktree_with_cleanup` and add a `defer_cleanup: bool` option
instead — simpler API, but less composable.
8. **Who cleans up the trash entry.** `remove_worktree_with_cleanup`
returns `staged_path` and asks the caller to remove it (sync or async).
Alternative: always spawn a detached `rm -rf` internally, like `wt
remove` does. Current choice favours library-friendliness (the binary's
background-cleanup helper isn't something `worktrunk-sync` should take a
dependency on), at the cost of a small amount of extra code in every
caller.
9. **Direct error return vs captured `branch_result`.** Branch-deletion
errors are captured in `branch_result` rather than propagated; worktree
removal is treated as the primary operation. Alternative: fail-fast on
`branch -D` errors so callers don't have to inspect the result. Kept the
current shape because the TUI picker and `worktrunk-sync --prune` both
want to continue the loop regardless of a single branch's deletion
failure.
Happy to take the refactor in whichever direction you prefer before
merging — none of this is locked in.
## Follow-up PR in worktrunk-sync
I'll open a PR against https://github.com/pablospe/worktrunk-sync
swapping the raw-git-command prune path for this new interface, once
this PR's shape is settled (otherwise the downstream PR would need to
change with it).
## Test plan
- [x] `cargo run -- hook pre-merge --yes` passes locally (fmt, clippy
with `-D warnings`, 573 unit tests, 1471 integration tests)
- [x] No behaviour change for `wt remove`, `wt merge --remove`, or the
TUI picker — all three go through the library entry point with the same
semantics as before
- [ ] CI green on Linux / macOS / Windows
- [ ] `codecov/patch` passes — new tests live in `src/git/remove.rs`;
the module exercised the same code paths via integration tests before
the move
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:43:51 -07:00
|
|
|
|
use worktrunk::git::BranchDeletionMode;
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
|
fix(picker): stash collect warnings until skim releases the terminal (#2627)
## Summary
`collect::collect` emits warnings on stderr (stale default branch,
batch-fetch failure, drain timeout, per-row task errors). On the `wt
list` path that's fine. On `wt switch`, collect runs on a background
thread while skim's TUI owns the terminal — eprintln overlays the
rendered frame and corrupts skim's clear math, leaving fragments visible
after the user picks.
Reproducer (synthetic picker-test repo with a stale
`worktrunk.default-branch` set):
```
▲ Configured default branch ghost-branch does not exist locally
↳ To reset, run wt config state default-branch clear
```
…appears overlaid on picker rows mid-render.
## Approach
Warnings flow through a new `PickerProgressHandler::stash_warning`. The
picker holds an `Arc<Mutex<Vec<String>>>` shared with its handler,
collect appends from the bg thread, and the picker drains and emits the
lines after `Skim::run_with` returns (and in the dry-run path after the
bg thread joins). Late warnings still in flight on the bg thread fall on
the floor with the thread, per the existing "don't join after skim"
rule.
`wt list`'s stderr behavior is unchanged — when `progressive_handler` is
`None`, the same closure writes straight to stderr.
The drain-timeout warning + hint that previously hardcoded `wt list` is
now subcommand-agnostic and follows `writing-user-outputs` patterns:
`"Listing worktrees timed out after Xs"`, command at end of clause,
semicolon between alternatives, `-vv` last.
## Test infrastructure
Three small extractions made the new code testable end-to-end and
brought patch coverage up from 66.7% to 100%:
- `drain_stashed_warnings(&Mutex<Vec<String>>)` in `picker/mod.rs` —
both drain call sites collapse to one line; helper body has dedicated
unit tests.
- `format_drain_timeout_diag(received_count, &items)` in
`collect/mod.rs` — pure formatter; snapshot-tested for the no-blocked
and blocked-items paths.
- `handle_drain_timeout(drain_outcome, collect_deadline, &emit)` in
`collect/mod.rs` — wraps the previously-untestable
`DrainOutcome::TimedOut` branch (`DRAIN_TIMEOUT` is 120s with no test
seam). Three unit tests synthesize `DrainOutcome` values directly to
cover all branches: timeout-fires, intentional-truncation,
complete-outcome.
Plus a new integration test in `switch_picker_dry_run.rs` that runs the
picker in dry-run mode against a stale `worktrunk.default-branch` and
asserts the warning + reset hint reach stderr after the bg thread joins.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` — 3508 tests pass, pre-commit
clean (8 new tests across the helpers above).
- [x] `wt list` warning snapshot tests still pass — non-picker stderr
unchanged.
- [x] Manual repro: `WORKTRUNK_PICKER_DRY_RUN=1 wt switch --no-cd`
against picker-test with a stale default branch now surfaces both
warning lines on stderr after the picker exits.
> _This was written by Claude Code on behalf of @max-sixty_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-06 20:39:01 -07:00
|
|
|
|
/// Empties the stash and emits each line. Verifies post-skim drain
|
|
|
|
|
|
/// semantics without standing up a real picker.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn drain_stashed_warnings_empties_the_stash() {
|
|
|
|
|
|
let stash = Mutex::new(vec!["one".to_string(), "two".to_string()]);
|
|
|
|
|
|
drain_stashed_warnings(&stash);
|
|
|
|
|
|
assert!(stash.lock().unwrap().is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A fresh stash with no warnings is a no-op — exercising the empty path
|
|
|
|
|
|
/// keeps the loop body covered when the picker exits cleanly.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn drain_stashed_warnings_handles_empty_stash() {
|
|
|
|
|
|
let stash: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
|
|
|
|
|
drain_stashed_warnings(&stash);
|
|
|
|
|
|
assert!(stash.lock().unwrap().is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
#[test]
|
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:52:46 -07:00
|
|
|
|
fn test_install_preview_tab_keybindings() {
|
|
|
|
|
|
use skim::binds::{KeyMap, parse_key};
|
|
|
|
|
|
use skim::prelude::Action;
|
|
|
|
|
|
|
|
|
|
|
|
// The native preview-tab switches replace skim's default bindings for
|
|
|
|
|
|
// these keys with exactly one custom action each. This asserts the
|
|
|
|
|
|
// wiring (keyed via skim's own `parse_key` so the lookup matches its
|
|
|
|
|
|
// event loop). Which tab each callback selects can't be asserted here
|
|
|
|
|
|
// (`Action::Custom` has no `Eq`), but the callbacks are built by a
|
|
|
|
|
|
// uniform `from_u8` loop — `from_u8`/`next`/`prev` are unit-tested in
|
|
|
|
|
|
// `preview`, and the `switch_picker` PTY tests drive the keys end-to-end.
|
|
|
|
|
|
let mut keymap = KeyMap::default();
|
|
|
|
|
|
install_preview_tab_keybindings(&mut keymap);
|
|
|
|
|
|
|
|
|
|
|
|
let mut specs: Vec<String> = (1..=7).map(|d| format!("alt-{d}")).collect();
|
|
|
|
|
|
specs.extend(["tab", "btab", "shift-btab", "shift-tab"].map(String::from));
|
|
|
|
|
|
for spec in specs {
|
|
|
|
|
|
let key = parse_key(&spec).expect("known key spec parses");
|
|
|
|
|
|
let chain = keymap
|
|
|
|
|
|
.get(&key)
|
|
|
|
|
|
.unwrap_or_else(|| panic!("{spec} not bound"));
|
|
|
|
|
|
assert_eq!(chain.len(), 1, "{spec} should bind a single action");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(chain[0], Action::Custom(_)),
|
|
|
|
|
|
"{spec} should bind a native custom action, got {:?}",
|
|
|
|
|
|
chain[0]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_install_shortcut_keybindings() {
|
|
|
|
|
|
use skim::binds::{KeyMap, parse_key};
|
|
|
|
|
|
use skim::prelude::Action;
|
|
|
|
|
|
|
|
|
|
|
|
// alt-y (copy branch) and alt-o (open URL) bind native custom actions
|
|
|
|
|
|
// that read the selected row off skim's `App` and look it up in the
|
|
|
|
|
|
// shortcut table — no shell binds, so they work cross-platform. The
|
|
|
|
|
|
// callback behavior (clipboard / browser) is driven by the `switch_picker`
|
|
|
|
|
|
// PTY tests; here we just assert the wiring, mirroring the tab test above.
|
|
|
|
|
|
let mut keymap = KeyMap::default();
|
|
|
|
|
|
let table = Arc::new(Mutex::new(std::collections::HashMap::new()));
|
|
|
|
|
|
install_shortcut_keybindings(&mut keymap, table);
|
|
|
|
|
|
|
|
|
|
|
|
for spec in ["alt-y", "alt-o"] {
|
|
|
|
|
|
let key = parse_key(spec).expect("known key spec parses");
|
|
|
|
|
|
let chain = keymap
|
|
|
|
|
|
.get(&key)
|
|
|
|
|
|
.unwrap_or_else(|| panic!("{spec} not bound"));
|
|
|
|
|
|
assert_eq!(chain.len(), 1, "{spec} should bind a single action");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(chain[0], Action::Custom(_)),
|
|
|
|
|
|
"{spec} should bind a native custom action, got {:?}",
|
|
|
|
|
|
chain[0]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The lookup the `alt-y` / `alt-o` closures delegate to — pure table logic,
|
|
|
|
|
|
/// no live skim `App`. Covers a row with a branch + URL, a detached row (no
|
|
|
|
|
|
/// branch, no URL — both shortcuts no-op), and a token absent from the table.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn resolve_shortcut_branch_and_url() {
|
|
|
|
|
|
use super::items::{RowShortcutData, RowUrl, ShortcutTable};
|
|
|
|
|
|
|
|
|
|
|
|
let table: ShortcutTable = Arc::new(Mutex::new(std::collections::HashMap::new()));
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut t = table.lock().unwrap();
|
|
|
|
|
|
t.insert(
|
|
|
|
|
|
"feat".into(),
|
|
|
|
|
|
RowShortcutData {
|
|
|
|
|
|
branch: Some("feat".into()),
|
|
|
|
|
|
url: RowUrl::Static(Some("https://example.test/pr/1".into())),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
morph: None,
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
t.insert(
|
|
|
|
|
|
"wt".into(),
|
|
|
|
|
|
RowShortcutData {
|
|
|
|
|
|
branch: None,
|
|
|
|
|
|
url: RowUrl::Static(None),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
morph: None,
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
resolve_shortcut_branch(&table, "feat").as_deref(),
|
|
|
|
|
|
Some("feat")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(resolve_shortcut_branch(&table, "wt"), None); // detached: no branch
|
|
|
|
|
|
assert_eq!(resolve_shortcut_branch(&table, "missing"), None);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
resolve_shortcut_url(&table, "feat").as_deref(),
|
|
|
|
|
|
Some("https://example.test/pr/1")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(resolve_shortcut_url(&table, "wt"), None); // no URL
|
|
|
|
|
|
assert_eq!(resolve_shortcut_url(&table, "missing"), None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 22:12:16 -07:00
|
|
|
|
#[test]
|
2026-03-16 09:12:01 -07:00
|
|
|
|
fn test_resolve_identifier() {
|
2026-03-11 22:12:16 -07:00
|
|
|
|
// Switch returns the selected name
|
2026-03-16 09:12:01 -07:00
|
|
|
|
let result = resolve_identifier(
|
2026-03-11 22:12:16 -07:00
|
|
|
|
&PickerAction::Switch,
|
|
|
|
|
|
String::new(),
|
|
|
|
|
|
Some("feature/foo".into()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(result.unwrap(), "feature/foo");
|
|
|
|
|
|
|
2026-03-16 09:12:01 -07:00
|
|
|
|
// Switch with no selection and empty query
|
|
|
|
|
|
let result = resolve_identifier(&PickerAction::Switch, String::new(), None);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
result
|
|
|
|
|
|
.unwrap_err()
|
|
|
|
|
|
.to_string()
|
|
|
|
|
|
.contains("No worktree selected")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Switch with no selection but a query — the panic from #1565
|
|
|
|
|
|
let result = resolve_identifier(&PickerAction::Switch, "nonexistent".into(), None);
|
|
|
|
|
|
let err = result.unwrap_err().to_string();
|
|
|
|
|
|
assert!(err.contains("No worktree matches 'nonexistent'"));
|
|
|
|
|
|
assert!(err.contains("alt-c"));
|
2026-03-11 22:12:16 -07:00
|
|
|
|
|
|
|
|
|
|
// Create returns the query
|
2026-03-16 09:12:01 -07:00
|
|
|
|
let result = resolve_identifier(&PickerAction::Create, "new-branch".into(), None);
|
2026-03-11 22:12:16 -07:00
|
|
|
|
assert_eq!(result.unwrap(), "new-branch");
|
|
|
|
|
|
|
|
|
|
|
|
// Create with empty query is an error
|
2026-03-16 09:12:01 -07:00
|
|
|
|
let result = resolve_identifier(&PickerAction::Create, String::new(), None);
|
2026-03-11 22:12:16 -07:00
|
|
|
|
assert!(result.unwrap_err().to_string().contains("no branch name"));
|
2026-03-23 12:18:42 -07:00
|
|
|
|
}
|
2026-03-11 22:12:16 -07:00
|
|
|
|
|
fix(switch): trim redundant Repository rebuilds on the accept path (#3557)
## Summary
Follow-up to #3544's `wt switch` review, from investigating why the
accept path re-forks `git config --list -z` and rebuilds `Repository`
more than necessary.
- Skip the destination-rooted `Repository::at()` in
`spawn_switch_background_hooks` when the approved hook plan is empty
(the common no-project-hooks case) — `HookAnnouncer::flush()` is already
a no-op there, so this changes no behavior, only cost.
- Fix the picker's `is_recovered` accept-path arm, which reused the
startup-time `Repository` unconditionally. An in-picker alt-x/alt-r
during a recovered session can mutate the worktree/branch inventory, and
that arm never rebuilt to see it — the non-recovered arm already gets
this via a fresh `Repository::current()`. Rebuild via `Repository::at`
(mirrors the picker's own `rebuild_repo` idiom) instead of
`Repository::current()`, which fails after a deleted-CWD recovery.
- Replace a misleading comment ("reuse the recovered repo") with the
actual freshness rationale.
A fourth change — sharing the bulk config cache across same-process
`Repository` instances by `git_common_dir` — was implemented and then
reverted: the pre-merge gate's
`test_primary_remote_honours_checkout_default_remote` caught a real
staleness bug (a config mutation between two `Repository::at()` calls,
e.g. from a switch hook, would go unobserved by a cache hit with no
invalidation path). Measured idle-repo cost of the remaining duplicated
config forks is small (~5-10ms each), and the risk of a bespoke
process-wide cache with no write-invalidation wasn't worth it.
## Test plan
- [x] `cargo run -- hook pre-merge --yes` (full test + lint suite): 4483
tests passed
- [x] `cargo clippy --all-targets --features shell-integration-tests --
-D warnings`: clean
- [x] Targeted tests: `git::repository::`, `picker::`,
`worktree::switch::`, `hook_plan::`
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:50:04 -07:00
|
|
|
|
/// `is_recovered=true` must rebuild rather than reuse `repo`: a worktree
|
|
|
|
|
|
/// added after `repo`'s `OnceCell` list is primed must be visible through
|
|
|
|
|
|
/// the returned `Repository`, and not through the stale one — proving the
|
|
|
|
|
|
/// fix actually observes post-mutation state instead of just "not
|
|
|
|
|
|
/// panicking".
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_switch_pipeline_repo_recovered_rebuilds_fresh() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let stale_repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
// Prime the worktree-list cache before the mutation, same as the
|
|
|
|
|
|
// picker priming its startup repo.
|
|
|
|
|
|
assert!(stale_repo.worktree_for_branch("feature").unwrap().is_none());
|
|
|
|
|
|
|
|
|
|
|
|
stale_repo
|
|
|
|
|
|
.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
test.path()
|
|
|
|
|
|
.parent()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.join("feature")
|
|
|
|
|
|
.to_str()
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let fresh_repo = switch_pipeline_repo(&stale_repo, true).unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
fresh_repo.worktree_for_branch("feature").unwrap().is_some(),
|
|
|
|
|
|
"recovered arm must rebuild fresh so a worktree added after the \
|
|
|
|
|
|
startup snapshot was primed is visible"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
stale_repo.worktree_for_branch("feature").unwrap().is_none(),
|
|
|
|
|
|
"the startup repo's own cache stays stale, confirming the fix \
|
|
|
|
|
|
rebuilds rather than mutates in place"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 19:18:22 -07:00
|
|
|
|
/// `from_signal` rejects tokens that carry no usable target: a blank or
|
|
|
|
|
|
/// whitespace-only signal, and a bare `worktree-path:` prefix with no path
|
|
|
|
|
|
/// after it. A non-empty branch token and a prefixed path both parse.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_picker_removal_target_from_signal() {
|
|
|
|
|
|
assert!(PickerRemovalTarget::from_signal("").is_none());
|
|
|
|
|
|
assert!(PickerRemovalTarget::from_signal(" ").is_none());
|
|
|
|
|
|
assert!(PickerRemovalTarget::from_signal("worktree-path:").is_none());
|
|
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
PickerRemovalTarget::from_signal("feature/foo"),
|
|
|
|
|
|
Some(PickerRemovalTarget::Branch(branch)) if branch == "feature/foo"
|
|
|
|
|
|
));
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
PickerRemovalTarget::from_signal("worktree-path:/tmp/wt"),
|
|
|
|
|
|
Some(PickerRemovalTarget::WorktreePath(path)) if path == std::path::Path::new("/tmp/wt")
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `picker_item_identifier` yields the worktree path for every
|
|
|
|
|
|
/// worktree-backed row — branched as well as detached — and the branch name
|
|
|
|
|
|
/// for a branch-only row, matching what each row's `output()` token carries.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_picker_item_identifier() {
|
|
|
|
|
|
let branched = branched_picker_item("feature/foo", Path::new("/tmp/wt-branched"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
picker_item_identifier(branched.as_ref()),
|
|
|
|
|
|
"/tmp/wt-branched"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let detached = detached_picker_item(Path::new("/tmp/wt-detached"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
picker_item_identifier(detached.as_ref()),
|
|
|
|
|
|
"/tmp/wt-detached"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let branch_only = branch_only_picker_item("feature/bar");
|
|
|
|
|
|
assert_eq!(picker_item_identifier(branch_only.as_ref()), "feature/bar");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 12:18:42 -07:00
|
|
|
|
#[test]
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
fn test_do_removal_removes_worktree_and_branch() {
|
2026-04-07 09:48:59 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let wt_path = wt_dir.path().join("feature");
|
2026-03-23 12:18:42 -07:00
|
|
|
|
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
repo.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
wt_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
2026-03-23 12:18:42 -07:00
|
|
|
|
assert!(wt_path.exists());
|
|
|
|
|
|
|
|
|
|
|
|
let result = RemoveResult::RemovedWorktree {
|
2026-04-07 09:48:59 -07:00
|
|
|
|
main_path: test.path().to_path_buf(),
|
2026-03-23 12:18:42 -07:00
|
|
|
|
worktree_path: wt_path.clone(),
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: Some("feature".to_string()),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
2026-03-16 09:12:01 -07:00
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();
|
2026-03-23 12:18:42 -07:00
|
|
|
|
assert!(!wt_path.exists(), "worktree should be removed");
|
|
|
|
|
|
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
let output = repo.run_command(&["branch", "--list", "feature"]).unwrap();
|
|
|
|
|
|
assert!(output.is_empty(), "branch should be deleted");
|
2026-03-23 12:18:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
fn test_do_removal_branch_only_deletes_integrated_branch() {
|
2026-04-07 09:48:59 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
|
|
|
|
|
|
// Create a branch at the same commit (fully integrated into main)
|
|
|
|
|
|
repo.run_command(&["branch", "feature"]).unwrap();
|
|
|
|
|
|
|
2026-03-23 12:18:42 -07:00
|
|
|
|
let result = RemoveResult::BranchOnly {
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
branch_name: "feature".to_string(),
|
2026-03-23 12:18:42 -07:00
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
pruned: false,
|
2026-04-07 14:00:59 -07:00
|
|
|
|
target_branch: None,
|
|
|
|
|
|
integration_reason: None,
|
2026-03-23 12:18:42 -07:00
|
|
|
|
};
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
|
|
|
|
|
|
let output = repo.run_command(&["branch", "--list", "feature"]).unwrap();
|
|
|
|
|
|
assert!(output.is_empty(), "integrated branch should be deleted");
|
2026-03-11 22:12:16 -07:00
|
|
|
|
}
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_do_removal_branch_only_retains_unmerged_branch() {
|
2026-04-07 09:48:59 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
|
|
|
|
|
|
// Create a branch with an unmerged commit
|
|
|
|
|
|
repo.run_command(&["checkout", "-b", "unmerged"]).unwrap();
|
2026-04-07 09:48:59 -07:00
|
|
|
|
fs::write(test.path().join("new.txt"), "unmerged work").unwrap();
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
repo.run_command(&["add", "."]).unwrap();
|
|
|
|
|
|
repo.run_command(&["commit", "-m", "unmerged work"])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
repo.run_command(&["checkout", "main"]).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let result = RemoveResult::BranchOnly {
|
|
|
|
|
|
branch_name: "unmerged".to_string(),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
pruned: false,
|
2026-04-07 14:00:59 -07:00
|
|
|
|
target_branch: None,
|
|
|
|
|
|
integration_reason: None,
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
};
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();
|
fix: picker alt-r removal — validate before removing, use fast path (#1702)
Two bugs in picker alt-r removal, plus consolidation of the removal code
path.
**Validate before removing from list.** Previously, `invoke()` removed
items from the picker list optimistically, then ran
`prepare_worktree_removal` in a background thread. If validation failed
(dirty worktree, locked, etc.), the item was already gone from the UI.
Now `prepare_worktree_removal` runs synchronously in `invoke()`
(~15-20ms) and the item is only removed if validation passes.
**Use the fast removal path.** The picker called `git worktree remove`
(slow — worktree directory persists until the command finishes). Now
`execute_removal` tries rename-to-trash first (instant on same
filesystem), falling back to `git worktree remove` on cross-filesystem
setups. This is the same `stage_worktree_removal` primitive the
background handler uses. The foreground `--foreground` path also gets
the fast path, which means it now handles non-writable subdirectories
that previously caused `git worktree remove` to fail.
**Branch-only deletion.** `do_removal` previously returned `Ok(())` for
`BranchOnly` items. Now it calls `delete_branch_if_safe`, matching what
`wt remove` does.
Other changes: `prepare_worktree_removal` takes `current_path:
Option<PathBuf>` for CWD-independence, CWD check uses
`changed_directory` from `RemoveResult` instead of re-listing worktrees,
test helper extracted, tests use `repo.run_command()`.
TUI-only code in `invoke()` can't be tested without interactive skim —
verified via tmux-cli.
> _This was written by Claude Code on behalf of maximilian_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 20:34:23 -07:00
|
|
|
|
|
|
|
|
|
|
// Branch should be retained — SafeDelete won't delete unmerged branches
|
|
|
|
|
|
let output = repo.run_command(&["branch", "--list", "unmerged"]).unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!output.is_empty(),
|
|
|
|
|
|
"unmerged branch should be retained with SafeDelete"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 00:24:49 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_do_removal_removes_detached_worktree() {
|
2026-04-07 09:48:59 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let wt_path = wt_dir.path().join("detached");
|
2026-03-25 00:24:49 -07:00
|
|
|
|
|
|
|
|
|
|
repo.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"to-detach",
|
|
|
|
|
|
wt_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Detach HEAD in the new worktree
|
2026-04-07 09:48:59 -07:00
|
|
|
|
worktrunk::shell_exec::Cmd::new("git")
|
2026-03-25 00:24:49 -07:00
|
|
|
|
.args(["checkout", "--detach", "HEAD"])
|
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
|
.run()
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
assert!(wt_path.exists());
|
|
|
|
|
|
|
|
|
|
|
|
let result = RemoveResult::RemovedWorktree {
|
2026-04-07 09:48:59 -07:00
|
|
|
|
main_path: test.path().to_path_buf(),
|
2026-03-25 00:24:49 -07:00
|
|
|
|
worktree_path: wt_path.clone(),
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: None,
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();
|
2026-03-25 00:24:49 -07:00
|
|
|
|
assert!(!wt_path.exists(), "detached worktree should be removed");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 19:18:22 -07:00
|
|
|
|
/// A branch-only row's signal carries the bare branch name, which
|
|
|
|
|
|
/// `PickerRemovalTarget::from_signal` decodes to `Branch`; `prepare_removal`
|
|
|
|
|
|
/// then resolves it to the branch-only disposition.
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_prepare_removal_resolves_branch_only_item() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
2026-05-21 19:18:22 -07:00
|
|
|
|
// A branch at the same commit as main, with no worktree.
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
repo.run_command(&["branch", "branch-only-feature"])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::new(Mutex::new(Vec::new())), repo);
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
|
2026-05-21 19:18:22 -07:00
|
|
|
|
let target = PickerRemovalTarget::from_signal("branch-only-feature").unwrap();
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let (_planning_repo, result) = remover.prepare_removal(&target).unwrap();
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(&result, RemoveResult::BranchOnly { branch_name, .. } if branch_name == "branch-only-feature"),
|
|
|
|
|
|
"a branch with no worktree should resolve to BranchOnly"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A selection that names neither a worktree nor a local branch fails the
|
|
|
|
|
|
/// `prepare_worktree_removal` validation, so `prepare_removal` returns the
|
|
|
|
|
|
/// error rather than touching the picker list.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_prepare_removal_errors_on_unknown_target() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::new(Mutex::new(Vec::new())), repo);
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
|
|
|
|
|
|
// `RemoveResult` isn't `Debug`; drop the Ok payload so `unwrap_err`
|
|
|
|
|
|
// (which needs `T: Debug`) can report a failure cleanly.
|
2026-05-21 19:18:22 -07:00
|
|
|
|
let target = PickerRemovalTarget::from_signal("no-such-branch").unwrap();
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let err = remover
|
2026-05-21 19:18:22 -07:00
|
|
|
|
.prepare_removal(&target)
|
fix: picker cache, switch ref resolution, and statusline scans (#2842)
Correctness and performance fixes for worktrunk's git-operation paths —
the interactive picker, `wt switch` reference resolution, and the
statusline — surfaced by an automated `clawpatch` review and then
independently re-reviewed against the `reviewing-code` checklist (all
SOLID / Clean-design, zero critical issues).
## Fixes
- **The picker uses fresh repository state for each removal** — after a
removal mutated git's worktree inventory, the next `alt-r` reload could
plan against a stale cache.
- **`wt switch` prefers an exact local branch over stripping a remote
prefix** — a local branch literally named `origin/foo` was previously
retargeted.
- **`wt switch` fails closed on a malformed config when selecting a PR
provider** — a typo in `forge.platform` was silently swallowed and fell
back to GitHub.
- **A single-row statusline skips the repo-wide ahead/behind scan** — a
performance fix for large repositories.
- **Benchmark fix** — the piped-list time-to-first-output benchmark
measured a boundary that emitted no output.
- A clippy `collapsible_if` cleanup.
## Scope
Pure code — the clawpatch tooling and its state files are excluded. One
commit from the source branch that made the statusline skip network
CI/summary tasks was intentionally dropped: a statusline configured with
a CI segment is treated as an explicit opt-in to that network access, so
the statusline keeps its current behavior.
## Coverage
`codecov/patch` reports a gap on `invoke()`'s `changed_directory`
branch. Covering it in-process requires calling `invoke()`, which
mutates process-global cwd (`std::env::set_current_dir`) — a global side
effect `tests/CLAUDE.md` ("No Global State Mutations in Tests") forbids.
Per that doc's own guidance — accept a coverage gap rather than take
global side effects for coverage — the branch is left uncovered, and the
`codecov/patch` miss on those lines is accepted as a justified false
positive.
## Testing
`cargo run -- hook pre-merge --yes` — 3791 tests pass, lints clean.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:08:05 -07:00
|
|
|
|
.map(|_| ())
|
|
|
|
|
|
.expect_err("unknown removal target should fail validation");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
err.to_string().contains("no-such-branch"),
|
|
|
|
|
|
"error should name the unresolved target: {err:#}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
/// A `pre-remove` hook the user hasn't approved must not run when the
|
|
|
|
|
|
/// picker removes the worktree — the picker can't prompt mid-render, so
|
|
|
|
|
|
/// unapproved project commands are skipped. The git removal still happens.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_do_removal_skips_unapproved_pre_remove_hook() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let wt_path = wt_dir.path().join("feature");
|
|
|
|
|
|
repo.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
wt_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
// A `pre-remove` hook in the invoking worktree's `.config/wt.toml` —
|
|
|
|
|
|
// the config the picker removal resolves against — that would write a
|
|
|
|
|
|
// marker if it ever ran.
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
let marker_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let marker = marker_dir.path().join("pre-remove-ran");
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
fs::create_dir_all(test.path().join(".config")).unwrap();
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
fs::write(
|
fix(hooks): resolve all hook config from the invoking worktree (#2873)
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.
This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.
## Behavior changes
- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.
In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.
## For reviewers
The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.
## Testing
Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.
Ref #2856, #2818.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:56:14 -07:00
|
|
|
|
test.path().join(".config/wt.toml"),
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
format!("pre-remove = {:?}\n", format!("touch {}", marker.display())),
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let result = RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path: test.path().to_path_buf(),
|
|
|
|
|
|
worktree_path: wt_path.clone(),
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: Some("feature".to_string()),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
fix(hooks): structurally close the approval-boundary TOCTOU class (#2806)
## Why
Project-defined hook commands (`pre-*`/`post-*`) are arbitrary code
shipped in a repo the user may have just cloned. They were selected from
`.config/wt.toml` **twice**: once at the approval gate to build the
prompt, and again at execution when `register`/`execute_hook` re-read
`load_project_config()`. Between the two reads, the operation itself
mutates state — a merge moves the target ref, an auto-rebase rewrites
the feature config, a removal scrubs the worktree, `git worktree add`
materializes a `--create` worktree — so the second read could select a
command the user never approved. On a fresh `git clone && wt <op>` that
is remote code execution. On `main` the post-merge path was entirely
unpinned; the others used point-fix config snapshots that the executor
could still re-resolve around.
## Approach
The gate selects the command set exactly once and freezes it into an
immutable, type-state `ApprovedHookPlan` (new
`src/commands/hook_plan.rs`). Covered executors consume only that value
via `execute_planned_hook` / `register_planned` and hold no
`ProjectConfig`/`Repository` for selection, so re-derivation is a
compile error, not a review invariant. Rendering stays deferred
(post-`*` hooks legitimately need post-operation context like the merge
commit) but consumes the frozen `CommandConfig` list, never config.
Covered (gate and execution separated by a state mutation): `pre-merge`,
`post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`,
`post-start`. Deliberately not covered — they have no gate→exec mutation
window and share the gate's cached `Repository`: `pre-commit`,
`post-commit`, `pre-switch`, `wt hook <type>`, aliases. This scope
boundary is documented in the `commands::hooks` module spec.
Clean cutover: the point-fix snapshot apparatus is deleted
(`RemoveResult::removed_project_config`, `register_with_project_config`,
`collect_remove_hook_commands`, `collect_merge_commands`,
`removal_hooks_approved`, `approve_or_skip_with_config`) — no parallel
path, no compatibility flag.
## Reviewer orientation
- `src/commands/hook_plan.rs` — the whole model: `HookPlanBuilder` (sole
config→commands point), type-state `HookPlan` → `ApprovedHookPlan`
(constructible only via `approve`/`approve_readonly`/`empty`),
`lookup`/`render_planned`. Start here.
- `merge.rs` / `main.rs` / `step/prune.rs` / `worktree/switch.rs` /
`picker/mod.rs` — the five gates that build a plan.
- `output/handlers.rs` / `worktree/finish.rs` — the executors that
consume it.
- `commands::hooks` module doc — the canonical "which `.config/wt.toml`
a hook reads" spec, rewritten for the plan model including why the
uncovered set is safe (shared never-invalidated config cache).
Behavior parity is preserved: an empty plan (`--no-hooks`, declined, or
no project config) runs no project hooks; the merge approval prompt is
unchanged (still lists pre-commit/post-commit); the picker's read-only
gate drops only unapproved project pipelines (strictly better than the
old all-or-nothing verify boolean). The empty-plan fast path returns
before any `Approvals` load or project-id resolution, so a malformed
`approvals.toml` no longer aborts a command with nothing to authorize,
and `wt merge --no-hooks` no longer parses the destination config. The
removal data-safety re-validation, the Ctrl-C signal policy, and
source-scoped filtering are untouched.
## Testing
`cargo run -- hook pre-merge --yes` green (3751 tests), clippy +
pre-commit clean. New regression tests:
`test_post_merge_hook_from_merged_feature_config_does_not_run` (the
TOCTOU itself, causally bounded),
`test_remove_no_project_hooks_ignores_malformed_approvals`,
`test_merge_no_hooks_ignores_malformed_destination_config`, plus
`hook_plan` unit tests (frozen lookup, read-only filter, source-group
ordering). Reviewed across eight structurally-distinct passes
(adversarial, generalization, evidential, subtraction, metric,
classification, holistic) plus a Codex review whose two P2 findings are
fixed and locked with the malformed-config tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:12:59 -07:00
|
|
|
|
// Empty approvals → `approve_readonly` drops the unapproved project
|
|
|
|
|
|
// `pre-remove` pipeline from the plan, so it never runs.
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
let approvals = Approvals::default();
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
AltXRemover::do_removal(&repo, &result, &approvals).unwrap();
|
refactor(picker): route alt-r removal through handle_remove_output (#2746)
The picker's `alt-r` removal (`PickerCollector::do_removal`) was a
parallel reimplementation of the `wt remove` teardown pipeline —
`pre-remove` hook → git worktree removal → `post-remove` (and
`post-switch`) hooks — that had to be kept in lockstep with
`output::handle_remove_output` by hand. #2736 had to fix the same
hook-config bug in both. This routes `do_removal` through
`handle_remove_output`, so the duplication is gone, and gates the
picker's hooks on approval (which the old path didn't do).
## What changed
**`handle_remove_output` gains a `silent` flag**
(`src/output/handlers.rs`). When set, a `RemovedWorktree` result is
removed with no progress/success messages, no trash-cleanup spinner, and
no `cd` directive — just `pre-remove`, the synchronous git worktree
removal (`remove_removed_worktree_silently`), and `post-remove` /
`post-switch` hook registration. Required because the picker runs
`do_removal` from a background thread spawned off skim's event loop, so
any `wt`-generated stderr output would corrupt skim's frame. `silent` is
the only new knob; it threads through the five `handle_remove_output`
call sites as `false` everywhere except the picker.
**`do_removal` is now a thin call into `handle_remove_output`**
(`src/commands/picker/mod.rs`). The `RemovedWorktree` arm drops its
hand-rolled `pre-remove` (`CommandContext` + `execute_hook`) / git
removal / `post-remove` (`PostRemoveContext` +
`HookAnnouncer::register_with_project_config`) code — net ~80 lines,
plus the `removed_project_config` snapshot plumbing #2736 added now
lives in one place. The `BranchOnly` arm is unchanged (it's small and
`handle_remove_output`'s `BranchOnly` path prints things the picker
can't have).
**The picker's hooks now run only when already approved.** The old
`do_removal` ran project `pre-remove` / `post-remove` / `post-switch`
hooks *unconditionally* — it was the one removal/switch path that
bypassed the approval gate, so a freshly-cloned repo's `post-switch`
hook would fire on `alt-r`. The picker can't show an approval prompt
mid-render, so it now consults `Approvals` read-only
(`removal_hooks_approved` — the same `collect_remove_hook_commands` set
`wt remove` would prompt for, checked against
`Approvals::is_command_approved`) and passes the result as `verify`;
when it's `false`, `execute_pre_remove_hooks_if_needed` and
`spawn_hooks_after_remove` early-return. `CLAUDE.md` gains a "Project
Commands Run Only After Approval" section stating the general rule
(`src/commands/command_approval.rs` is the gate; never build a path that
runs project commands without it; a context that can't prompt skips the
unapproved ones).
**One behavior delta beyond consolidation:** removing the *current*
worktree via `alt-r` now also registers `post-switch` hooks for the home
worktree (if approved), matching `wt remove` / `wt merge` / `wt step
prune`. The old `do_removal` registered only `post-remove`.
## Testing
`cargo run -- hook pre-merge --yes` is green (3690 tests). The
`test_do_removal_*` unit tests cover the silent removal path (worktree +
branch + detached), and a new
`test_do_removal_skips_unapproved_pre_remove_hook` covers the approval
gating (`removal_hooks_approved` is fully covered). Not covered: the two
lines in `invoke()`'s `alt-r` spawn block that hand the `Approvals`
snapshot to the removal thread — `invoke()` is already documented as not
unit-testable ("Full invoke() tests require interactive skim"), and the
rest of its body is uncovered too; the approval-check logic itself is.
No end-to-end test exercises "hook present *and* approved → runs"
through the picker (would need seeding `Approvals` against the test
repo's path) — the unit test asserts the logic, just not that exact
integration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:18:58 -07:00
|
|
|
|
assert!(!wt_path.exists(), "worktree should be removed");
|
|
|
|
|
|
assert!(!marker.exists(), "unapproved pre-remove hook must not run");
|
|
|
|
|
|
}
|
2026-05-21 19:18:22 -07:00
|
|
|
|
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
/// Build a `PickerRow` from a snapshot `ListItem`.
|
2026-05-21 19:18:22 -07:00
|
|
|
|
fn picker_item(branch_name: &str, item: ListItem) -> Arc<dyn SkimItem> {
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
let item = Arc::new(item);
|
2026-06-22 16:34:04 -07:00
|
|
|
|
let pr_status = Arc::new(Mutex::new(item.pr_status.clone()));
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
let output_token = worktree_output_token(&item, branch_name);
|
|
|
|
|
|
Arc::new(PickerRow {
|
2026-06-25 17:55:54 -07:00
|
|
|
|
search_base: branch_name.to_string(),
|
|
|
|
|
|
gutter: '@',
|
2026-05-21 19:18:22 -07:00
|
|
|
|
rendered: Arc::new(Mutex::new(String::new())),
|
|
|
|
|
|
branch_name: branch_name.to_string(),
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
output_token,
|
2026-05-21 19:18:22 -07:00
|
|
|
|
preview_cache: Arc::new(dashmap::DashMap::new()),
|
2026-06-22 16:34:04 -07:00
|
|
|
|
pr_status,
|
fix(switch): auto-refresh picker preview when a background compute lands (#3247)
## Problem
The `wt switch` picker's preview pane is served from a `DashMap` cache
filled by background workers on `COLLECT_POOL` (a `git diff HEAD`, a
`git log`, a forge `gh pr view`). skim 4.8 re-reads that cache **only**
inside `run_preview`, which fires only on `Event::RunPreview` — produced
by a selection change or a preview-tab keystroke. The cache-insert path
didn't poke skim, so a compute that finished *after* the single
`RunPreview` the keystroke produced sat in the cache with no event to
surface it: the pane stayed on its `Loading…` placeholder until the user
pressed a key again. That `Press alt-N again to refresh` text was the
manual workaround for exactly this gap, and it was a Windows-CI flake
(PR #3238 papered over it test-side by re-issuing the tab keystroke).
## The skim mechanism this uses
skim 4.8 hands the embedder its event sender at TUI init:
`Skim::event_sender()` returns the `tokio::sync::mpsc::Sender<Event>`
that drives the loop. The picker already captures it (as `render_tx`, a
shared `Arc<OnceLock<…>>`) and pushes `Event::Render` through it for
in-place row repaints. **Pushing `Event::RunPreview` through the same
channel forces `run_preview` to re-read the cache for the
currently-selected row + current mode** — the external injection point
the orchestrator needed. The channel is `1024*1024`-capacity, so the
`try_send` poke is never dropped.
## Approach
New `PreviewNotifier` (`src/commands/picker/preview_notify.rs`) closes
the producer → consumer loop:
- **Consumer side:** every `*SkimItem::preview()` records the selected
row's awaited `(row-key, mode)` via `note_awaiting` — *before* it reads
the cache. That ordering makes the hand-off race-free: if the read
misses, the fill that satisfies it necessarily lands after the read, so
it observes the awaited key already set.
- **Producer side:** the orchestrator routes **every** cache fill
through a single `PreviewOrchestrator::fill` / `fill_external` path,
which calls `notify_filled(key)`. That injects `Event::RunPreview`
**iff** the filled key matches what the selected row is awaiting. A fill
for an off-screen row or a tab the user isn't on matches nothing and
injects nothing — so background pre-compute never thrashes the visible
preview.
`preview()` is only ever called for the selected row, so the single
shared `awaiting` slot always reflects what's on screen; when the
selection changes, the next `RunPreview` updates it.
Two producers feed the panes, both now covered:
- **Orchestrator cache fills** (diff / log / summary / the `--prs`
comments & log fetch) → `notify_filled(key)`, exact-key match.
- **The collect handler's `on_update`** mirrors a row's live `pr_status`
(the `pr` / `comments` panes) and `local_content` (the diff tabs' dim
state) — not cache fills → `notify_row_changed(row_key)`, which re-runs
the selected row's preview on *any* tab when that row's data lands. This
is what flips the `pr` tab from "Fetching PR status…" to the resolved PR
on its own.
Wiring: `render_tx` is constructed before the orchestrator in
`handle_picker` and handed in; it's still published once, inside
`run_skim` after `init_tui`. `generate_and_cache_summary` became
`generate_summary_for_item` (returns the pane; the orchestrator inserts
via `fill`).
## User-visible change
The placeholders drop the now-obsolete "press alt-N … to refresh"
wording — the pane fills in on its own:
```
○ Loading working-tree diff… (was: ○ Loading working-tree diff. Press alt-1 again to refresh.)
○ Generating summary…
○ Fetching PR status for feature… (was: … press alt-6 to refresh)
ⓘ Loading comments… (--prs deferred tabs; was: … press alt-2 again to refresh)
```
## Testing
- `test_switch_picker_preview_auto_refreshes_when_compute_lands` (PTY):
mocks `gh pr view --json comments` behind a 3 s delay, opens a `--prs`
row's comments tab mid-fetch — the comment surfaces **with no further
input** (the orchestrator-fill path).
- `test_switch_picker_pr_tab_auto_resolves_from_fetching` (PTY): the
per-row CI fetch (`gh pr list --head`) is delayed 3 s and unseeded, so
the `pr` tab opens on "Fetching PR status…" and resolves to the live PR
on its own (the `on_update` path).
- Both verified to **time out (stay stranded) when the poke is
disabled**, so they genuinely exercise the mechanism rather than passing
on a cache hit.
- `fill_notifies_only_awaited_key` /
`notify_row_changed_pokes_only_the_selected_row` (unit): the poke fires
for the visible row+mode (resp. row, any mode) and nothing for
off-screen / other-tab keys — the no-thrash guarantee.
- The PTY driver's keystroke re-issue (`nudge` / `is_alt_digit_tab` /
`PREVIEW_REISSUE_INTERVAL`, PR #3238) is removed — the product now
auto-refreshes, so the tests genuinely verify it rather than papering
over a strand. All 37 `switch_picker` PTY tests pass; full `cargo run --
hook pre-merge --yes` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:24:23 -07:00
|
|
|
|
notifier: super::preview_notify::PreviewNotifier::detached(),
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
local: Some(LocalCheckout {
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
item,
|
|
|
|
|
|
demand: super::preview_orchestrator::PreviewDemand::new(),
|
|
|
|
|
|
spawn_gen: super::preview_orchestrator::SpawnGeneration::default(),
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
has_upstream: false,
|
|
|
|
|
|
summaries_enabled: false,
|
|
|
|
|
|
local_content: Arc::new(Mutex::new(LocalContent::default())),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
morphed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
}),
|
2026-05-21 19:18:22 -07:00
|
|
|
|
}) as Arc<dyn SkimItem>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
/// Build a `PickerRow` standing in for a detached-worktree row.
|
2026-05-21 19:18:22 -07:00
|
|
|
|
fn detached_picker_item(path: &Path) -> Arc<dyn SkimItem> {
|
|
|
|
|
|
let mut item = ListItem::new_branch("abc123".to_string(), "(detached)".to_string());
|
|
|
|
|
|
item.branch = None;
|
|
|
|
|
|
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
|
|
|
|
|
|
path: path.to_path_buf(),
|
|
|
|
|
|
detached: true,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}));
|
|
|
|
|
|
picker_item("(detached)", item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
/// Build a `PickerRow` standing in for a branched-worktree row.
|
2026-05-21 19:18:22 -07:00
|
|
|
|
fn branched_picker_item(branch: &str, path: &Path) -> Arc<dyn SkimItem> {
|
|
|
|
|
|
let mut item = ListItem::new_branch("abc123".to_string(), branch.to_string());
|
|
|
|
|
|
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
|
|
|
|
|
|
path: path.to_path_buf(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}));
|
|
|
|
|
|
picker_item(branch, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
/// Build a `PickerRow` standing in for a branch-only row (no worktree).
|
2026-05-21 19:18:22 -07:00
|
|
|
|
fn branch_only_picker_item(branch: &str) -> Arc<dyn SkimItem> {
|
|
|
|
|
|
picker_item(
|
|
|
|
|
|
branch,
|
|
|
|
|
|
ListItem::new_branch("abc123".to_string(), branch.to_string()),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// Build a morphable worktree row and register everything the morph path needs
|
|
|
|
|
|
/// — a [`MorphHandle`](items::MorphHandle) in the remover's shortcut table,
|
|
|
|
|
|
/// keyed by the row's `output()` token, and a real layout in its slot — so a
|
|
|
|
|
|
/// kept-branch removal morphs in place instead of falling back to a drop.
|
|
|
|
|
|
/// Returns the row, its token, and the shared `rendered` / `morphed` slots the
|
|
|
|
|
|
/// morph mutates (so a test can assert on them).
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
fn setup_morphable_row(
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover: &AltXRemover,
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
branch: &str,
|
|
|
|
|
|
path: &Path,
|
|
|
|
|
|
) -> (
|
|
|
|
|
|
Arc<dyn SkimItem>,
|
|
|
|
|
|
String,
|
|
|
|
|
|
Arc<Mutex<String>>,
|
|
|
|
|
|
Arc<std::sync::atomic::AtomicBool>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
let mut item = ListItem::new_branch("abc123".to_string(), branch.to_string());
|
|
|
|
|
|
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
|
|
|
|
|
|
path: path.to_path_buf(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}));
|
|
|
|
|
|
let item_arc = Arc::new(item);
|
|
|
|
|
|
let rendered = Arc::new(Mutex::new(format!("+ {branch}")));
|
|
|
|
|
|
let local_content = Arc::new(Mutex::new(LocalContent::default()));
|
|
|
|
|
|
let morphed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
|
|
|
|
let row: Arc<dyn SkimItem> = Arc::new(PickerRow {
|
|
|
|
|
|
search_base: branch.to_string(),
|
|
|
|
|
|
gutter: '+',
|
|
|
|
|
|
rendered: Arc::clone(&rendered),
|
|
|
|
|
|
branch_name: branch.to_string(),
|
|
|
|
|
|
output_token: worktree_output_token(&item_arc, branch),
|
|
|
|
|
|
preview_cache: Arc::new(dashmap::DashMap::new()),
|
|
|
|
|
|
pr_status: Arc::new(Mutex::new(None)),
|
|
|
|
|
|
notifier: super::preview_notify::PreviewNotifier::detached(),
|
|
|
|
|
|
local: Some(LocalCheckout {
|
feat(picker): serve the selected preview tab on demand (#3439)
In a large repo with dozens of worktrees, navigating to a preview tab in
`wt switch` (e.g. alt-3, the branch diff) shows "Loading…" for ~10
seconds. `SkimItem::preview` only reads the in-memory cache, so a missed
tab waited for the background precompute queue to reach it — behind the
row pipeline (hundreds of git subprocesses on `COLLECT_POOL`), the
per-row `gh` CI fetches inside the same drain (the picker is implicitly
`--full`, which disables the per-task timeouts), and then the mode-major
deferred tier. Disk caching never helped much because it only made the
queued task bodies cheap, not the queue position.
This adds a third preview producer: a demand worker. A `preview()` cache
miss on a local-git tab (working-tree, log, branch diff, upstream) posts
the row's item to a one-slot, latest-wins channel drained by a dedicated
thread, off `COLLECT_POOL` entirely. The worker computes through the
existing `compute_and_page_preview` path and lands through the existing
`fill` choke point, so the repaint-on-fill notify works unchanged. A
previously computed tab now fills from the SHA-keyed disk cache in
milliseconds; a cold one costs exactly its own git command. The one slot
means rapid navigation coalesces — rows skimmed past are never computed
— and precompute stays what it was: background backfill.
The second commit adds the structural fix the first one's docs deferred:
spawn generations. An `alt-r` refresh doesn't wait for the prior spawn's
producers — draining precompute tasks, an in-flight `--prs` forge call,
a parked demand — and each holds a frozen item whose `head()` the
refresh made stale; left alone they re-seed the just-cleared cache and
the new spawn short-circuits on the stale entry. Each pipeline spawn now
mints a `SpawnGeneration` token carried by everything it starts. `fill`
— the one insert path — drops a superseded write, checking the token
under the key's shard write lock so a preempted producer can't straddle
the bump-then-clear; the demand channel refuses superseded rows;
superseded queued tasks, a superseded `--prs` batch, a superseded
skeleton's shared-state publish, and a superseded handler's Comments
eviction are all inert before paying for doomed work.
`PreviewOrchestrator::refresh` bumps the generation, rebinds preview
compute to the rebuilt spawn's repo (BranchDiff bases stop resolving
from session-start state), and clears the cache in one place — subsuming
the factory's inline clear and `clear_pending`. The pre-existing
`prs_epoch` counter collapsed into the same token, so one spawn-identity
mechanism gates the `--prs` row append and every cache fill.
Remaining demand-worker guardrails from the first round: morphed rows
post no demand (their frozen item points at the worktree an alt-x
removal is deleting); a panicking compute is contained to its key
instead of silently killing the worker; the orchestrator's `Drop` closes
the channel so the thread releases the preview cache and repo when the
picker ends; and `LOCAL_GIT_MODES` is the single mode set both producers
consume.
Reviewer map: `preview_orchestrator.rs` has `PreviewDemand`,
`SpawnGeneration`, `refresh`, the worker loop, and the module spec (see
its *Spawn generations* section); `items.rs` hooks the miss in
`preview()` and adds `item`/`demand`/`spawn_gen` to `LocalCheckout`;
`progressive_handler.rs` carries the per-spawn token and gates the
superseded-handler paths; `prs.rs` gates the `--prs` batch and replaces
the epoch pair; `mod.rs` mints the token per spawn and routes the alt-r
rebuild through `refresh`.
Testing: an end-to-end unit test drives `preview()` → demand → worker →
fill against a real repo; each worker arm (duplicate-key skip, panic
containment, log-disk-hit refresh, request-after-close,
parked-across-refresh drop) has a direct deterministic test; the
generation mechanism is pinned by tests covering every superseded
producer path (pool preview/summary/compute/log-refresh and the `fill`
choke point itself), the stale-request refusal, the repo rebind, the
superseded skeleton, and the superseded Comments eviction. The pre-merge
gate (4404 tests) and the 68 PTY `switch_picker` tests with `--features
shell-integration-tests` pass locally. Verified against this repo's own
checkout (~20 worktrees): on `main`, alt-3 shortly after open sits on
"Loading branch diff…"; on this branch the pane is filled at the same
timing.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:07:39 -07:00
|
|
|
|
item: Arc::clone(&item_arc),
|
|
|
|
|
|
demand: super::preview_orchestrator::PreviewDemand::new(),
|
|
|
|
|
|
spawn_gen: super::preview_orchestrator::SpawnGeneration::default(),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
has_upstream: false,
|
|
|
|
|
|
summaries_enabled: false,
|
|
|
|
|
|
local_content: Arc::clone(&local_content),
|
|
|
|
|
|
morphed: Arc::clone(&morphed),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
let token = row.output().to_string();
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.shortcut_table.lock().unwrap().insert(
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
token.clone(),
|
|
|
|
|
|
super::items::RowShortcutData {
|
|
|
|
|
|
branch: Some(branch.to_string()),
|
|
|
|
|
|
url: super::items::RowUrl::Static(None),
|
|
|
|
|
|
morph: Some(super::items::MorphHandle {
|
|
|
|
|
|
item: Arc::clone(&item_arc),
|
|
|
|
|
|
rendered: Arc::clone(&rendered),
|
|
|
|
|
|
local_content,
|
|
|
|
|
|
morphed: Arc::clone(&morphed),
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
*remover.layout_slot.lock().unwrap() =
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
Some(crate::commands::list::layout::calculate_layout_with_width(
|
|
|
|
|
|
std::slice::from_ref(&*item_arc),
|
perf(list): plan background tasks from the columns being rendered (#3274)
## Problem
`[list] columns` filtered purely at the layout layer. A narrowed
selection like `columns = ["branch", "path"]` hid the unselected columns
but still ran every per-worktree git task — `git status`, working/branch
diffs, ahead/behind walks, merge-conflict probes — then threw the
results away. On the kind of repo that motivated #3133 (27 dirty
worktrees) that discarded work is the bulk of the wall-clock cost, so a
"just branch and path" view was no faster than the full table.
This was flagged in the [trace-based diagnosis on the
issue](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4816169750):
`columns = ["branch","path"]` and the default set produced an
**identical** command list. @max-sixty
[confirmed](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4819594461)
it's a bug and asked for the fix.
## Solution
`wt list` decides which background tasks to run in **one canonical
stage**, driven by the columns it will render. The plan flows through
the whole pipeline as a **positive set of tasks to run** — no skip-list,
no inversion, no blanket default.
`collect` computes `tasks` = the union of each rendered column's
`required_tasks()`, gated by the conditions that turn a column off
(`--full`, `[list] summary` + `[commit.generation]`, a url template).
The spawn loop fires exactly that set; the layout renders exactly the
columns it feeds. The rendered set is the `[list] columns` selection for
the table; the picker and `--format json` plan from every column,
because their consumers — the picker's preview tabs, JSON's every-field
contract — need the full data set, not just what renders.
This started as additive pruning layered on the old `skip_tasks`
denylist; review (thanks @max-sixty) pushed it to the canonical,
positive form:
- **One column→task map.** `ColumnSpec::requires_task` is deleted;
`ColumnKind::required_tasks()` is the single source, driving both the
spawn plan and the layout visibility filter (`renders_given_run` — a
column renders iff one of its tasks is in the plan). The two maps can no
longer drift, so the reconciliation test is gone; the `cover_every_task`
drift guard stays and gains teeth (an unconsumed task would never run,
not merely be computed and discarded).
- **A positive run set, end to end.** `CollectOptions` carries `tasks`
(the run set), not a skip set — `collect` threads the plan straight into
the spawn loops, the layout, and `max_pr_number` with no complement
step.
- **No blanket default.** `CollectOptions::for_columns(columns, gates)`
derives the plan; nothing hand-writes a task set. The statusline
declares what it renders (the full column set under full gates, no LLM
summary) instead of leaning on "default everything". The picker rides
`show_full` on `ShowConfig::Resolved`.
- **One mechanism for the summary.** The per-item `SummaryGenerate &&
llm.is_none()` spawn guard is dropped: the column plan is the single
authority on whether the summary runs, and `SummaryGenerateTask` already
returns a clean error on a missing command.
A branch/path `ls` alias over many dirty worktrees now runs no `git
status`, diffs, or ahead/behind walks (#3133), while a column gated off
elsewhere stays off. Behaviour is otherwise unchanged across
default/selection × full/non-full × table/JSON/picker/statusline — no
rendered-output snapshots move (the `help_config_*` snapshots move only
from the columns-doc rewrite).
## Testing
- Planner + filter units: `test_required_tasks_for_render` (the default
set needs every task; a branch/path or custom-column view needs none;
`Status` pulls in every status-feeding task; the gates drop
`ci`/`url`/`summary` even when those columns are explicitly selected)
and `test_renders_given_run` (the "render iff a task is planned" filter,
including `Status` surviving while any signal runs).
- `test_required_tasks_cover_every_task` drift guard retained: the union
of `required_tasks()` across all built-ins equals the full `TaskKind`
set, so no task can fall out of the now-load-bearing map.
- End-to-end: `test_list_config_columns_prune_unused_tasks` (default set
runs `git status --porcelain`; `columns = ["branch", "age"]` runs none)
and `test_list_json_ignores_columns_selection` (`--format json` emits
every field regardless of selection).
- Reviewed by independent finder passes (line-by-line +
removed-behavior, cross-file + picker/JSON equivalence, altitude +
conventions) — no findings; each confirmed the task set is preserved
bit-for-bit. Full `pre-merge` gate (all suites, fmt, clippy, docs-sync,
PTY picker snapshots) green after merging `main`.
Closes #3133
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-06-28 12:05:24 -07:00
|
|
|
|
&crate::commands::list::columns::all_tasks(),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
80,
|
|
|
|
|
|
Path::new("/test"),
|
|
|
|
|
|
None,
|
|
|
|
|
|
None,
|
|
|
|
|
|
crate::commands::list::layout::ColumnSelection {
|
|
|
|
|
|
custom: &[],
|
|
|
|
|
|
selected: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
));
|
|
|
|
|
|
(row, token, rendered, morphed)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// A real [`PipelineFactory`] with empty config for the removal / `invoke`
|
|
|
|
|
|
/// tests. Its `spawn()` is only reached by the refresh verb, which these
|
|
|
|
|
|
/// tests don't exercise, so the minimal field set is enough to satisfy the
|
|
|
|
|
|
/// type without standing up a full picker.
|
|
|
|
|
|
fn test_factory(repo: worktrunk::git::Repository) -> std::rc::Rc<super::PipelineFactory> {
|
fix(switch): auto-refresh picker preview when a background compute lands (#3247)
## Problem
The `wt switch` picker's preview pane is served from a `DashMap` cache
filled by background workers on `COLLECT_POOL` (a `git diff HEAD`, a
`git log`, a forge `gh pr view`). skim 4.8 re-reads that cache **only**
inside `run_preview`, which fires only on `Event::RunPreview` — produced
by a selection change or a preview-tab keystroke. The cache-insert path
didn't poke skim, so a compute that finished *after* the single
`RunPreview` the keystroke produced sat in the cache with no event to
surface it: the pane stayed on its `Loading…` placeholder until the user
pressed a key again. That `Press alt-N again to refresh` text was the
manual workaround for exactly this gap, and it was a Windows-CI flake
(PR #3238 papered over it test-side by re-issuing the tab keystroke).
## The skim mechanism this uses
skim 4.8 hands the embedder its event sender at TUI init:
`Skim::event_sender()` returns the `tokio::sync::mpsc::Sender<Event>`
that drives the loop. The picker already captures it (as `render_tx`, a
shared `Arc<OnceLock<…>>`) and pushes `Event::Render` through it for
in-place row repaints. **Pushing `Event::RunPreview` through the same
channel forces `run_preview` to re-read the cache for the
currently-selected row + current mode** — the external injection point
the orchestrator needed. The channel is `1024*1024`-capacity, so the
`try_send` poke is never dropped.
## Approach
New `PreviewNotifier` (`src/commands/picker/preview_notify.rs`) closes
the producer → consumer loop:
- **Consumer side:** every `*SkimItem::preview()` records the selected
row's awaited `(row-key, mode)` via `note_awaiting` — *before* it reads
the cache. That ordering makes the hand-off race-free: if the read
misses, the fill that satisfies it necessarily lands after the read, so
it observes the awaited key already set.
- **Producer side:** the orchestrator routes **every** cache fill
through a single `PreviewOrchestrator::fill` / `fill_external` path,
which calls `notify_filled(key)`. That injects `Event::RunPreview`
**iff** the filled key matches what the selected row is awaiting. A fill
for an off-screen row or a tab the user isn't on matches nothing and
injects nothing — so background pre-compute never thrashes the visible
preview.
`preview()` is only ever called for the selected row, so the single
shared `awaiting` slot always reflects what's on screen; when the
selection changes, the next `RunPreview` updates it.
Two producers feed the panes, both now covered:
- **Orchestrator cache fills** (diff / log / summary / the `--prs`
comments & log fetch) → `notify_filled(key)`, exact-key match.
- **The collect handler's `on_update`** mirrors a row's live `pr_status`
(the `pr` / `comments` panes) and `local_content` (the diff tabs' dim
state) — not cache fills → `notify_row_changed(row_key)`, which re-runs
the selected row's preview on *any* tab when that row's data lands. This
is what flips the `pr` tab from "Fetching PR status…" to the resolved PR
on its own.
Wiring: `render_tx` is constructed before the orchestrator in
`handle_picker` and handed in; it's still published once, inside
`run_skim` after `init_tui`. `generate_and_cache_summary` became
`generate_summary_for_item` (returns the pane; the orchestrator inserts
via `fill`).
## User-visible change
The placeholders drop the now-obsolete "press alt-N … to refresh"
wording — the pane fills in on its own:
```
○ Loading working-tree diff… (was: ○ Loading working-tree diff. Press alt-1 again to refresh.)
○ Generating summary…
○ Fetching PR status for feature… (was: … press alt-6 to refresh)
ⓘ Loading comments… (--prs deferred tabs; was: … press alt-2 again to refresh)
```
## Testing
- `test_switch_picker_preview_auto_refreshes_when_compute_lands` (PTY):
mocks `gh pr view --json comments` behind a 3 s delay, opens a `--prs`
row's comments tab mid-fetch — the comment surfaces **with no further
input** (the orchestrator-fill path).
- `test_switch_picker_pr_tab_auto_resolves_from_fetching` (PTY): the
per-row CI fetch (`gh pr list --head`) is delayed 3 s and unseeded, so
the `pr` tab opens on "Fetching PR status…" and resolves to the live PR
on its own (the `on_update` path).
- Both verified to **time out (stay stranded) when the poke is
disabled**, so they genuinely exercise the mechanism rather than passing
on a cache hit.
- `fill_notifies_only_awaited_key` /
`notify_row_changed_pokes_only_the_selected_row` (unit): the poke fires
for the visible row+mode (resp. row, any mode) and nothing for
off-screen / other-tab keys — the no-thrash guarantee.
- The PTY driver's keystroke re-issue (`nudge` / `is_alt_digit_tab` /
`PREVIEW_REISSUE_INTERVAL`, PR #3238) is removed — the product now
auto-refreshes, so the tests genuinely verify it rather than papering
over a strand. All 37 `switch_picker` PTY tests pass; full `cargo run --
hook pre-merge --yes` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:24:23 -07:00
|
|
|
|
let render_tx = Arc::new(OnceLock::new());
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let orchestrator = Arc::new(super::preview_orchestrator::PreviewOrchestrator::new(
|
|
|
|
|
|
repo.clone(),
|
fix(switch): auto-refresh picker preview when a background compute lands (#3247)
## Problem
The `wt switch` picker's preview pane is served from a `DashMap` cache
filled by background workers on `COLLECT_POOL` (a `git diff HEAD`, a
`git log`, a forge `gh pr view`). skim 4.8 re-reads that cache **only**
inside `run_preview`, which fires only on `Event::RunPreview` — produced
by a selection change or a preview-tab keystroke. The cache-insert path
didn't poke skim, so a compute that finished *after* the single
`RunPreview` the keystroke produced sat in the cache with no event to
surface it: the pane stayed on its `Loading…` placeholder until the user
pressed a key again. That `Press alt-N again to refresh` text was the
manual workaround for exactly this gap, and it was a Windows-CI flake
(PR #3238 papered over it test-side by re-issuing the tab keystroke).
## The skim mechanism this uses
skim 4.8 hands the embedder its event sender at TUI init:
`Skim::event_sender()` returns the `tokio::sync::mpsc::Sender<Event>`
that drives the loop. The picker already captures it (as `render_tx`, a
shared `Arc<OnceLock<…>>`) and pushes `Event::Render` through it for
in-place row repaints. **Pushing `Event::RunPreview` through the same
channel forces `run_preview` to re-read the cache for the
currently-selected row + current mode** — the external injection point
the orchestrator needed. The channel is `1024*1024`-capacity, so the
`try_send` poke is never dropped.
## Approach
New `PreviewNotifier` (`src/commands/picker/preview_notify.rs`) closes
the producer → consumer loop:
- **Consumer side:** every `*SkimItem::preview()` records the selected
row's awaited `(row-key, mode)` via `note_awaiting` — *before* it reads
the cache. That ordering makes the hand-off race-free: if the read
misses, the fill that satisfies it necessarily lands after the read, so
it observes the awaited key already set.
- **Producer side:** the orchestrator routes **every** cache fill
through a single `PreviewOrchestrator::fill` / `fill_external` path,
which calls `notify_filled(key)`. That injects `Event::RunPreview`
**iff** the filled key matches what the selected row is awaiting. A fill
for an off-screen row or a tab the user isn't on matches nothing and
injects nothing — so background pre-compute never thrashes the visible
preview.
`preview()` is only ever called for the selected row, so the single
shared `awaiting` slot always reflects what's on screen; when the
selection changes, the next `RunPreview` updates it.
Two producers feed the panes, both now covered:
- **Orchestrator cache fills** (diff / log / summary / the `--prs`
comments & log fetch) → `notify_filled(key)`, exact-key match.
- **The collect handler's `on_update`** mirrors a row's live `pr_status`
(the `pr` / `comments` panes) and `local_content` (the diff tabs' dim
state) — not cache fills → `notify_row_changed(row_key)`, which re-runs
the selected row's preview on *any* tab when that row's data lands. This
is what flips the `pr` tab from "Fetching PR status…" to the resolved PR
on its own.
Wiring: `render_tx` is constructed before the orchestrator in
`handle_picker` and handed in; it's still published once, inside
`run_skim` after `init_tui`. `generate_and_cache_summary` became
`generate_summary_for_item` (returns the pane; the orchestrator inserts
via `fill`).
## User-visible change
The placeholders drop the now-obsolete "press alt-N … to refresh"
wording — the pane fills in on its own:
```
○ Loading working-tree diff… (was: ○ Loading working-tree diff. Press alt-1 again to refresh.)
○ Generating summary…
○ Fetching PR status for feature… (was: … press alt-6 to refresh)
ⓘ Loading comments… (--prs deferred tabs; was: … press alt-2 again to refresh)
```
## Testing
- `test_switch_picker_preview_auto_refreshes_when_compute_lands` (PTY):
mocks `gh pr view --json comments` behind a 3 s delay, opens a `--prs`
row's comments tab mid-fetch — the comment surfaces **with no further
input** (the orchestrator-fill path).
- `test_switch_picker_pr_tab_auto_resolves_from_fetching` (PTY): the
per-row CI fetch (`gh pr list --head`) is delayed 3 s and unseeded, so
the `pr` tab opens on "Fetching PR status…" and resolves to the live PR
on its own (the `on_update` path).
- Both verified to **time out (stay stranded) when the poke is
disabled**, so they genuinely exercise the mechanism rather than passing
on a cache hit.
- `fill_notifies_only_awaited_key` /
`notify_row_changed_pokes_only_the_selected_row` (unit): the poke fires
for the visible row+mode (resp. row, any mode) and nothing for
off-screen / other-tab keys — the no-thrash guarantee.
- The PTY driver's keystroke re-issue (`nudge` / `is_alt_digit_tab` /
`PREVIEW_REISSUE_INTERVAL`, PR #3238) is removed — the product now
auto-refreshes, so the tests genuinely verify it rather than papering
over a strand. All 37 `switch_picker` PTY tests pass; full `cargo run --
hook pre-merge --yes` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:24:23 -07:00
|
|
|
|
Arc::clone(&render_tx),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
));
|
|
|
|
|
|
let preview_cache = Arc::clone(&orchestrator.cache);
|
|
|
|
|
|
std::rc::Rc::new(super::PipelineFactory {
|
|
|
|
|
|
repo,
|
fix(switch): auto-refresh picker preview when a background compute lands (#3247)
## Problem
The `wt switch` picker's preview pane is served from a `DashMap` cache
filled by background workers on `COLLECT_POOL` (a `git diff HEAD`, a
`git log`, a forge `gh pr view`). skim 4.8 re-reads that cache **only**
inside `run_preview`, which fires only on `Event::RunPreview` — produced
by a selection change or a preview-tab keystroke. The cache-insert path
didn't poke skim, so a compute that finished *after* the single
`RunPreview` the keystroke produced sat in the cache with no event to
surface it: the pane stayed on its `Loading…` placeholder until the user
pressed a key again. That `Press alt-N again to refresh` text was the
manual workaround for exactly this gap, and it was a Windows-CI flake
(PR #3238 papered over it test-side by re-issuing the tab keystroke).
## The skim mechanism this uses
skim 4.8 hands the embedder its event sender at TUI init:
`Skim::event_sender()` returns the `tokio::sync::mpsc::Sender<Event>`
that drives the loop. The picker already captures it (as `render_tx`, a
shared `Arc<OnceLock<…>>`) and pushes `Event::Render` through it for
in-place row repaints. **Pushing `Event::RunPreview` through the same
channel forces `run_preview` to re-read the cache for the
currently-selected row + current mode** — the external injection point
the orchestrator needed. The channel is `1024*1024`-capacity, so the
`try_send` poke is never dropped.
## Approach
New `PreviewNotifier` (`src/commands/picker/preview_notify.rs`) closes
the producer → consumer loop:
- **Consumer side:** every `*SkimItem::preview()` records the selected
row's awaited `(row-key, mode)` via `note_awaiting` — *before* it reads
the cache. That ordering makes the hand-off race-free: if the read
misses, the fill that satisfies it necessarily lands after the read, so
it observes the awaited key already set.
- **Producer side:** the orchestrator routes **every** cache fill
through a single `PreviewOrchestrator::fill` / `fill_external` path,
which calls `notify_filled(key)`. That injects `Event::RunPreview`
**iff** the filled key matches what the selected row is awaiting. A fill
for an off-screen row or a tab the user isn't on matches nothing and
injects nothing — so background pre-compute never thrashes the visible
preview.
`preview()` is only ever called for the selected row, so the single
shared `awaiting` slot always reflects what's on screen; when the
selection changes, the next `RunPreview` updates it.
Two producers feed the panes, both now covered:
- **Orchestrator cache fills** (diff / log / summary / the `--prs`
comments & log fetch) → `notify_filled(key)`, exact-key match.
- **The collect handler's `on_update`** mirrors a row's live `pr_status`
(the `pr` / `comments` panes) and `local_content` (the diff tabs' dim
state) — not cache fills → `notify_row_changed(row_key)`, which re-runs
the selected row's preview on *any* tab when that row's data lands. This
is what flips the `pr` tab from "Fetching PR status…" to the resolved PR
on its own.
Wiring: `render_tx` is constructed before the orchestrator in
`handle_picker` and handed in; it's still published once, inside
`run_skim` after `init_tui`. `generate_and_cache_summary` became
`generate_summary_for_item` (returns the pane; the orchestrator inserts
via `fill`).
## User-visible change
The placeholders drop the now-obsolete "press alt-N … to refresh"
wording — the pane fills in on its own:
```
○ Loading working-tree diff… (was: ○ Loading working-tree diff. Press alt-1 again to refresh.)
○ Generating summary…
○ Fetching PR status for feature… (was: … press alt-6 to refresh)
ⓘ Loading comments… (--prs deferred tabs; was: … press alt-2 again to refresh)
```
## Testing
- `test_switch_picker_preview_auto_refreshes_when_compute_lands` (PTY):
mocks `gh pr view --json comments` behind a 3 s delay, opens a `--prs`
row's comments tab mid-fetch — the comment surfaces **with no further
input** (the orchestrator-fill path).
- `test_switch_picker_pr_tab_auto_resolves_from_fetching` (PTY): the
per-row CI fetch (`gh pr list --head`) is delayed 3 s and unseeded, so
the `pr` tab opens on "Fetching PR status…" and resolves to the live PR
on its own (the `on_update` path).
- Both verified to **time out (stay stranded) when the poke is
disabled**, so they genuinely exercise the mechanism rather than passing
on a cache hit.
- `fill_notifies_only_awaited_key` /
`notify_row_changed_pokes_only_the_selected_row` (unit): the poke fires
for the visible row+mode (resp. row, any mode) and nothing for
off-screen / other-tab keys — the no-thrash guarantee.
- The PTY driver's keystroke re-issue (`nudge` / `is_alt_digit_tab` /
`PREVIEW_REISSUE_INTERVAL`, PR #3238) is removed — the product now
auto-refreshes, so the tests genuinely verify it rather than papering
over a strand. All 37 `switch_picker` PTY tests pass; full `cargo run --
hook pre-merge --yes` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:24:23 -07:00
|
|
|
|
render_tx,
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
shared_items: Arc::new(Mutex::new(Vec::new())),
|
|
|
|
|
|
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
|
|
|
|
|
preview_cache,
|
|
|
|
|
|
orchestrator,
|
|
|
|
|
|
stashed_warnings: Arc::new(Mutex::new(Vec::new())),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
layout_slot: Arc::new(Mutex::new(None)),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
header_flash: Arc::new(super::items::HeaderFlash::default()),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
preview_dims: (80, 24),
|
|
|
|
|
|
skim_list_width: 80,
|
|
|
|
|
|
command_timeout: None,
|
|
|
|
|
|
llm_command: None,
|
|
|
|
|
|
summary_hint: None,
|
|
|
|
|
|
show_branches: false,
|
|
|
|
|
|
show_remotes: false,
|
|
|
|
|
|
show_prs: false,
|
|
|
|
|
|
is_preview_bench: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// An [`AltXRemover`] for the removal tests, wrapping the given `items` and
|
|
|
|
|
|
/// `repo`. Its shortcut-table / layout / `stashed_warnings` come from a fresh
|
|
|
|
|
|
/// `test_factory` so [`setup_morphable_row`] can register a morph handle and a
|
|
|
|
|
|
/// test can assert on stashed warnings.
|
|
|
|
|
|
fn test_remover(
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
|
|
|
|
|
|
repo: worktrunk::git::Repository,
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
) -> AltXRemover {
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
let factory = test_factory(repo.clone());
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
AltXRemover {
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
items,
|
|
|
|
|
|
repo,
|
|
|
|
|
|
approvals: Arc::new(Approvals::default()),
|
|
|
|
|
|
render_tx: Arc::new(OnceLock::new()),
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
stashed_warnings: Arc::clone(&factory.stashed_warnings),
|
|
|
|
|
|
shortcut_table: Arc::clone(&factory.shortcut_table),
|
|
|
|
|
|
layout_slot: Arc::clone(&factory.layout_slot),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
header_flash: Arc::clone(&factory.header_flash),
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 04:27:29 -07:00
|
|
|
|
/// Poll `git branch --list <branch>` until it succeeds and the branch
|
|
|
|
|
|
/// reaches the expected presence, tolerating the transient `exit 128` a
|
|
|
|
|
|
/// concurrent background removal can trigger.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `apply` renames the worktree into the trash (so its path vanishes) and
|
|
|
|
|
|
/// then runs `git worktree prune`, which deletes the `.git/worktrees/<id>`
|
|
|
|
|
|
/// admin dir — all on a background thread. `git branch --list` enumerates
|
|
|
|
|
|
/// worktrees to mark checked-out branches, so a query that races the
|
|
|
|
|
|
/// in-flight prune can read a half-deleted admin dir and fail with
|
|
|
|
|
|
/// `exit 128`. A test that `.unwrap()`s such a query flakes; retry until the
|
|
|
|
|
|
/// prune settles and the branch has reached its steady state.
|
|
|
|
|
|
fn await_branch_presence(
|
|
|
|
|
|
repo: &worktrunk::git::Repository,
|
|
|
|
|
|
branch: &str,
|
|
|
|
|
|
expect_present: bool,
|
|
|
|
|
|
) {
|
|
|
|
|
|
worktrunk::testing::wait_for(
|
|
|
|
|
|
&format!(
|
|
|
|
|
|
"branch `{branch}` to become {}",
|
|
|
|
|
|
if expect_present {
|
|
|
|
|
|
"retained"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"deleted"
|
|
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
|| {
|
|
|
|
|
|
// A transient prune race surfaces as `Err`, not a successful
|
|
|
|
|
|
// empty read, so only a successful query is authoritative.
|
|
|
|
|
|
repo.run_command(&["branch", "--list", branch])
|
|
|
|
|
|
.map(|list| list.is_empty() != expect_present)
|
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 19:18:22 -07:00
|
|
|
|
/// Two detached worktrees both render the branch label `(detached)`, but
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// each row's `output()` token carries its unique path. alt-x on the
|
2026-05-21 19:18:22 -07:00
|
|
|
|
/// second row must remove exactly that worktree — not the first detached
|
|
|
|
|
|
/// one a branch-name match would resolve to — and drop only its row.
|
|
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_removes_selected_detached_worktree_by_path_token() {
|
2026-05-21 19:18:22 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let first_path = wt_dir.path().join("detached-one");
|
|
|
|
|
|
let second_path = wt_dir.path().join("detached-two");
|
|
|
|
|
|
|
|
|
|
|
|
for (branch, path) in [
|
|
|
|
|
|
("to-detach-one", first_path.as_path()),
|
|
|
|
|
|
("to-detach-two", second_path.as_path()),
|
|
|
|
|
|
] {
|
|
|
|
|
|
repo.run_command(&["worktree", "add", "-b", branch, path.to_str().unwrap()])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
worktrunk::shell_exec::Cmd::new("git")
|
|
|
|
|
|
.args(["checkout", "--detach", "HEAD"])
|
|
|
|
|
|
.current_dir(path)
|
|
|
|
|
|
.run()
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let reported_paths: Vec<_> = repo
|
|
|
|
|
|
.list_worktrees()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|wt| wt.branch.is_none())
|
|
|
|
|
|
.map(|wt| wt.path.clone())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
let first_reported = reported_paths
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|path| path.file_name().is_some_and(|name| name == "detached-one"))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let second_reported = reported_paths
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|path| path.file_name().is_some_and(|name| name == "detached-two"))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let first_item = detached_picker_item(first_reported);
|
|
|
|
|
|
let second_item = detached_picker_item(second_reported);
|
|
|
|
|
|
let first_output = first_item.output().to_string();
|
|
|
|
|
|
let second_output = second_item.output().to_string();
|
|
|
|
|
|
assert_ne!(first_output, second_output);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
picker_item_identifier(second_item.as_ref()),
|
|
|
|
|
|
second_reported.to_string_lossy()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![
|
|
|
|
|
|
Arc::clone(&first_item),
|
|
|
|
|
|
Arc::clone(&second_item),
|
|
|
|
|
|
]));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::clone(&items), repo.clone());
|
2026-05-21 19:18:22 -07:00
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// alt-x's callback hands `apply` the selected row's `output()` token.
|
|
|
|
|
|
remover.apply(second_output.clone());
|
2026-05-21 19:18:22 -07:00
|
|
|
|
|
|
|
|
|
|
let remaining: Vec<_> = items
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|item| item.output().to_string())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(remaining, vec![first_output]);
|
|
|
|
|
|
|
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
|
|
|
|
while second_path.exists() && Instant::now() < deadline {
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(first_path.exists(), "first detached worktree should remain");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!second_path.exists(),
|
|
|
|
|
|
"selected detached worktree should be removed"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 13:05:00 -07:00
|
|
|
|
/// A refresh (`alt-r`) re-runs `factory.spawn()`. The factory carries the
|
|
|
|
|
|
/// `Repository` whose worktree-list cache was primed at picker startup and
|
|
|
|
|
|
/// is never invalidated, so after a worktree disappears `spawn` must rebuild
|
|
|
|
|
|
/// a fresh `Repository` rather than re-probe that stale cache — otherwise the
|
|
|
|
|
|
/// refresh streams a row for the gone worktree and collect's per-worktree git
|
|
|
|
|
|
/// ops fail against its deleted branch ("fatal: Needed a single revision").
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_spawn_reenumerates_worktrees_after_removal() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
// The repo the factory carries; its cache is primed below, as the
|
|
|
|
|
|
// picker prelude primes it at startup.
|
|
|
|
|
|
let factory_repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let doomed_path = wt_dir.path().join("doomed");
|
|
|
|
|
|
factory_repo
|
|
|
|
|
|
.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"doomed",
|
|
|
|
|
|
doomed_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let primed_has_doomed = factory_repo
|
|
|
|
|
|
.list_worktrees()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|wt| wt.branch.as_deref() == Some("doomed"));
|
|
|
|
|
|
assert!(primed_has_doomed, "cache primed while doomed still present");
|
|
|
|
|
|
|
|
|
|
|
|
// Remove the worktree through a separate fresh `Repository`, exactly as
|
|
|
|
|
|
// the picker's background removal does. `factory_repo`'s cache is now
|
|
|
|
|
|
// stale: it still lists `doomed`.
|
|
|
|
|
|
let removal_repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
removal_repo
|
|
|
|
|
|
.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"remove",
|
|
|
|
|
|
"--force",
|
|
|
|
|
|
doomed_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
removal_repo
|
|
|
|
|
|
.run_command(&["branch", "-D", "doomed"])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let factory = test_factory(factory_repo);
|
|
|
|
|
|
// `true` models the refresh path (`alt-r`), which rebuilds a fresh repo;
|
|
|
|
|
|
// the initial spawn (`false`) deliberately reuses the startup inventory.
|
|
|
|
|
|
let super::SpawnedPipeline {
|
|
|
|
|
|
rx,
|
|
|
|
|
|
handler,
|
|
|
|
|
|
collect_handle,
|
|
|
|
|
|
..
|
|
|
|
|
|
} = factory.spawn(true).unwrap();
|
|
|
|
|
|
// Drop the returned handler's sender, then wait for the collect thread
|
|
|
|
|
|
// to finish (dropping its handler clone); the lone senders gone, `rx`
|
|
|
|
|
|
// hits EOF. The unbounded channel buffered every streamed row.
|
|
|
|
|
|
drop(handler);
|
|
|
|
|
|
collect_handle.join().unwrap();
|
|
|
|
|
|
let outputs: Vec<String> = std::iter::from_fn(|| rx.recv().ok())
|
|
|
|
|
|
.flatten()
|
|
|
|
|
|
.map(|item| item.output().into_owned())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!outputs.is_empty(),
|
|
|
|
|
|
"the surviving worktree should still stream a row"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!outputs.iter().any(|out| out.contains("doomed")),
|
|
|
|
|
|
"refresh must not stream the removed worktree: {outputs:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(picker): refresh previews on alt-r, not just the row list (#3293)
## What
`alt-r` in the `wt switch` picker re-ran `collect` (refreshing the rows,
CI, and worktree inventory) but the **preview pane kept serving stale
content**. The in-memory preview cache is keyed by `(branch, mode)` with
no SHA, and the same cache was deliberately shared "warm" across reloads
— so a refresh never recomputed the working-tree / log / branch-diff /
upstream / summary tabs. Edit a tracked file, hit `alt-r`, and the diff
pane still showed the pre-edit state.
This clears the in-memory preview cache on the refresh spawn
(`PipelineFactory::spawn`, gated on `rebuild_repo` — true only on
`alt-r`), so each rebuilt row recomputes against its current
`item.head()`. The SHA- and diff-hash-keyed on-disk caches make an
unchanged branch a cheap re-read; only genuinely changed content pays a
recompute. `pr` / `comments` are cleared too, so a refresh also
re-fetches their forge data.
## Also: a unifying spec
There was no single write-up of the picker's preview-caching system —
the knowledge was scattered across four module docstrings. This adds a
module-level spec at the top of `preview_orchestrator.rs` (the hub that
owns the in-memory cache, the `fill` choke point, and the precompute
tiers): the two tiers, what backs each mode on a miss, the invalidation
rules, and exactly what `alt-r` does and doesn't refresh. Back-pointers
added from `preview_cache.rs` and `items.rs`.
## Known limitations (documented, not fixed here)
Both trace to one root — the orchestrator is built once and shares the
**startup** repo, with no spawn generation:
1. **Stale BranchDiff base** — if the *default* branch moves externally
mid-session, BranchDiff recomputes the row's fresh head against a stale
base SHA. Pre-existing and orthogonal to `alt-r`; not worsened here.
2. **Narrow stale-fill race** — a prior spawn's still-draining
precompute task can fill the just-cleared cache with stale content that
the new task then defers to. Opens only on a large repo / slow summaries
when content moved in the drain window; the common "I edited the branch
I'm viewing" case doesn't hit it (that branch's precompute finished at
picker open); self-heals on the next refresh.
The structural fix for both is to give the orchestrator the current
spawn's repo plus a generation counter (mirroring `prs_epoch`); left as
a follow-up since it's a refactor of a concurrency-critical module.
## Testing
- Unit test: cache cleared on a refresh spawn, kept warm on the initial
spawn.
- End-to-end PTY test: open the picker on a clean tree, edit a tracked
file, `alt-r`, assert the diff appears (and the stale "no uncommitted
changes" pane is gone).
Both were confirmed to fail with the one-line clear neutralized.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:26:27 -07:00
|
|
|
|
/// A refresh (`alt-r`, `spawn(true)`) drops the warm in-memory preview cache
|
|
|
|
|
|
/// so each rebuilt row recomputes against the fresh repo; the initial spawn
|
|
|
|
|
|
/// (`spawn(false)`) keeps it warm. The probe entry is keyed under a branch
|
|
|
|
|
|
/// with no row, so the background precompute never re-fills it — the entry's
|
|
|
|
|
|
/// fate is the clear alone, not a race with recompute.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_refresh_clears_preview_cache_initial_spawn_keeps_it() {
|
|
|
|
|
|
use super::preview::PreviewMode;
|
|
|
|
|
|
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let factory = test_factory(repo);
|
|
|
|
|
|
let ghost = ("ghost-branch".to_string(), PreviewMode::WorkingTree);
|
|
|
|
|
|
|
|
|
|
|
|
// Initial spawn (`false`) preserves warm previews.
|
|
|
|
|
|
factory
|
|
|
|
|
|
.preview_cache
|
|
|
|
|
|
.insert(ghost.clone(), "warm".to_string());
|
|
|
|
|
|
let super::SpawnedPipeline {
|
|
|
|
|
|
handler,
|
|
|
|
|
|
collect_handle,
|
|
|
|
|
|
..
|
|
|
|
|
|
} = factory.spawn(false).unwrap();
|
|
|
|
|
|
drop(handler);
|
|
|
|
|
|
collect_handle.join().unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
factory.preview_cache.contains_key(&ghost),
|
|
|
|
|
|
"initial spawn must keep the preview cache warm"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Refresh (`true`) drops them.
|
|
|
|
|
|
let super::SpawnedPipeline {
|
|
|
|
|
|
handler,
|
|
|
|
|
|
collect_handle,
|
|
|
|
|
|
..
|
|
|
|
|
|
} = factory.spawn(true).unwrap();
|
|
|
|
|
|
drop(handler);
|
|
|
|
|
|
collect_handle.join().unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!factory.preview_cache.contains_key(&ghost),
|
|
|
|
|
|
"refresh must clear the in-memory preview cache"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// alt-x with nothing selectable under the cursor hands `apply` an empty token;
|
|
|
|
|
|
/// `apply` must treat it as a no-op and leave the list intact.
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_empty_token_is_noop() {
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let item = branch_only_picker_item("some-branch");
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::clone(&items), repo);
|
|
|
|
|
|
remover.apply(String::new());
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
items.lock().unwrap().len(),
|
|
|
|
|
|
1,
|
|
|
|
|
|
"empty selection must not remove anything"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// alt-x on a target that fails validation (a branch with no worktree and no
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// local ref) takes `apply`'s error arm: it logs and leaves the list intact —
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// no drop, no background work.
|
|
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_leaves_list_intact_when_prepare_fails() {
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let item = branch_only_picker_item("real-row");
|
|
|
|
|
|
let token = item.output().to_string();
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::clone(&items), repo);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
// `no-such-branch` parses as a branch target but has no worktree and no
|
|
|
|
|
|
// local ref, so `prepare_removal` errors before anything is dropped.
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.apply("no-such-branch".to_string());
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
let outputs: Vec<String> = items
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|item| item.output().into_owned())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
outputs,
|
|
|
|
|
|
vec![token],
|
|
|
|
|
|
"a target that fails validation leaves the row untouched"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `restore_failed_removal` puts a dropped row back at its original slot and
|
|
|
|
|
|
/// stashes a `worktree kept` warning — the correction path that keeps the
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// alt-x list from showing a removal that didn't happen.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_restore_failed_removal_reinserts_row_and_stashes_warning() {
|
|
|
|
|
|
// The list as it stands after `dropped-b` (originally shared_items
|
|
|
|
|
|
// index 2) was optimistically dropped: a header at 0, two surviving
|
|
|
|
|
|
// data rows.
|
|
|
|
|
|
let items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>> = Arc::new(Mutex::new(vec![
|
|
|
|
|
|
branch_only_picker_item("header"),
|
|
|
|
|
|
branch_only_picker_item("keep-a"),
|
|
|
|
|
|
branch_only_picker_item("keep-c"),
|
|
|
|
|
|
]));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// A live sender so the restore queues its resync action rather
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
// than the early return.
|
|
|
|
|
|
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
|
|
|
|
|
|
Arc::new(OnceLock::new());
|
|
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
|
|
|
|
|
render_tx.set(tx).unwrap();
|
|
|
|
|
|
let stashed = Arc::new(Mutex::new(Vec::new()));
|
2026-07-01 11:43:14 -07:00
|
|
|
|
let header_flash = Arc::new(super::items::HeaderFlash::default());
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
super::restore_failed_removal(
|
|
|
|
|
|
&items,
|
2026-07-01 11:43:14 -07:00
|
|
|
|
&header_flash,
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
&render_tx,
|
|
|
|
|
|
&stashed,
|
2026-07-01 11:43:14 -07:00
|
|
|
|
super::DroppedRow {
|
|
|
|
|
|
item: branch_only_picker_item("dropped-b"),
|
|
|
|
|
|
pos: 2,
|
|
|
|
|
|
label: "dropped-b".to_string(),
|
|
|
|
|
|
noun: "worktree",
|
|
|
|
|
|
},
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let outputs: Vec<String> = items
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|item| item.output().into_owned())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
outputs,
|
|
|
|
|
|
vec!["header", "keep-a", "dropped-b", "keep-c"],
|
|
|
|
|
|
"row restored at its original slot"
|
|
|
|
|
|
);
|
|
|
|
|
|
let warnings = stashed.lock().unwrap();
|
|
|
|
|
|
assert_eq!(warnings.len(), 1, "one warning stashed");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
warnings[0].contains("dropped-b") && warnings[0].contains("Kept"),
|
|
|
|
|
|
"warning names the kept worktree: {}",
|
|
|
|
|
|
warnings[0]
|
|
|
|
|
|
);
|
2026-07-01 11:43:14 -07:00
|
|
|
|
// The same `could not remove` reason flashes in the header, styled as a
|
|
|
|
|
|
// warning (▲) — a genuine failure, not the keep paths' by-design info (○).
|
|
|
|
|
|
let flash = header_flash.current();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
flash.as_deref().is_some_and(|f| {
|
|
|
|
|
|
f.contains('▲') && f.contains("dropped-b") && f.contains("could not remove")
|
|
|
|
|
|
}),
|
|
|
|
|
|
"the failed removal flashes the reason in the header: {flash:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
// The flash queues a repaint first, then the restore re-shows the row by
|
|
|
|
|
|
// queuing a pool-resync Custom action.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(rx.try_recv(), Ok(skim::prelude::Event::Render)),
|
|
|
|
|
|
"the flash queues a repaint"
|
|
|
|
|
|
);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
assert!(
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
matches!(rx.try_recv(), Ok(skim::prelude::Event::Action(_))),
|
|
|
|
|
|
"restore queues a resync action when the sender is live"
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Restoring a row that's already back is a no-op — no duplicate, no extra
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// warning. Guards rapid repeated alt-x racing on the same row.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_restore_failed_removal_skips_when_already_present() {
|
|
|
|
|
|
let row = branch_only_picker_item("present");
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&row)]));
|
|
|
|
|
|
let render_tx = Arc::new(OnceLock::new());
|
|
|
|
|
|
let stashed = Arc::new(Mutex::new(Vec::new()));
|
2026-07-01 11:43:14 -07:00
|
|
|
|
let header_flash = Arc::new(super::items::HeaderFlash::default());
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
2026-07-01 11:43:14 -07:00
|
|
|
|
super::restore_failed_removal(
|
|
|
|
|
|
&items,
|
|
|
|
|
|
&header_flash,
|
|
|
|
|
|
&render_tx,
|
|
|
|
|
|
&stashed,
|
|
|
|
|
|
super::DroppedRow {
|
|
|
|
|
|
item: row,
|
|
|
|
|
|
pos: 0,
|
|
|
|
|
|
label: "present".to_string(),
|
|
|
|
|
|
noun: "worktree",
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
assert_eq!(items.lock().unwrap().len(), 1, "no duplicate inserted");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
stashed.lock().unwrap().is_empty(),
|
|
|
|
|
|
"no warning when there's nothing to restore"
|
|
|
|
|
|
);
|
2026-07-01 11:43:14 -07:00
|
|
|
|
assert!(
|
|
|
|
|
|
header_flash.current().is_none(),
|
|
|
|
|
|
"the already-present early return skips the flash too"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `revert_morph` undoes an optimistic morph after the worktree removal failed:
|
|
|
|
|
|
/// it restores the row's pre-morph display, clears the `morphed` flag, moves the
|
|
|
|
|
|
/// shortcut entry back to the worktree token, stashes a `kept … could not remove`
|
|
|
|
|
|
/// warning, and flashes the same reason in the header. The in-place-morph mirror
|
|
|
|
|
|
/// of `restore_failed_removal`.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_revert_morph_restores_row_and_flashes() {
|
|
|
|
|
|
let rendered = Arc::new(Mutex::new("/ feature".to_string()));
|
|
|
|
|
|
let morphed = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
|
|
|
|
|
let local_content = Arc::new(Mutex::new(LocalContent::default()));
|
|
|
|
|
|
|
|
|
|
|
|
// A shortcut entry keyed under the branch token, as the morph left it, so the
|
|
|
|
|
|
// revert can move it back to the worktree token.
|
|
|
|
|
|
let mut table_map = std::collections::HashMap::new();
|
|
|
|
|
|
table_map.insert(
|
|
|
|
|
|
"feature".to_string(),
|
|
|
|
|
|
super::items::RowShortcutData {
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
url: super::items::RowUrl::Static(None),
|
|
|
|
|
|
morph: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
let shortcut_table = Arc::new(Mutex::new(table_map));
|
|
|
|
|
|
|
|
|
|
|
|
let revert = super::MorphRevert {
|
|
|
|
|
|
rendered: Arc::clone(&rendered),
|
|
|
|
|
|
original_rendered: "+ feature".to_string(),
|
|
|
|
|
|
morphed: Arc::clone(&morphed),
|
|
|
|
|
|
local_content: Arc::clone(&local_content),
|
|
|
|
|
|
original_local: LocalContent::default(),
|
|
|
|
|
|
shortcut_table: Arc::clone(&shortcut_table),
|
|
|
|
|
|
branch_token: "feature".to_string(),
|
|
|
|
|
|
worktree_token: "worktree-path:/tmp/wt-feature".to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// A live sender so `flash_header` sets the flash rather than early-returning.
|
|
|
|
|
|
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
|
|
|
|
|
|
Arc::new(OnceLock::new());
|
|
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
|
|
|
|
|
render_tx.set(tx).unwrap();
|
|
|
|
|
|
let header_flash = Arc::new(super::items::HeaderFlash::default());
|
|
|
|
|
|
let stashed = Arc::new(Mutex::new(Vec::new()));
|
|
|
|
|
|
|
|
|
|
|
|
super::revert_morph(revert, &header_flash, &stashed, &render_tx);
|
|
|
|
|
|
|
|
|
|
|
|
// The row un-morphs in place: pre-morph line restored, flag cleared.
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
*rendered.lock().unwrap(),
|
|
|
|
|
|
"+ feature",
|
|
|
|
|
|
"the pre-morph display line is restored"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!morphed.load(std::sync::atomic::Ordering::Relaxed),
|
|
|
|
|
|
"the morphed flag is cleared"
|
|
|
|
|
|
);
|
|
|
|
|
|
// The shortcut entry moves back from the branch token to the worktree token.
|
|
|
|
|
|
{
|
|
|
|
|
|
let table = shortcut_table.lock().unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
table.contains_key("worktree-path:/tmp/wt-feature")
|
|
|
|
|
|
&& !table.contains_key("feature"),
|
|
|
|
|
|
"the shortcut entry is re-keyed to the worktree token"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// The `could not remove` reason is stashed (drains to stderr on exit)...
|
|
|
|
|
|
{
|
|
|
|
|
|
let warnings = stashed.lock().unwrap();
|
|
|
|
|
|
assert_eq!(warnings.len(), 1, "one warning stashed");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
warnings[0].contains("feature") && warnings[0].contains("could not remove"),
|
|
|
|
|
|
"warning names the kept worktree: {}",
|
|
|
|
|
|
warnings[0]
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// ...and flashes in the header now, styled as a warning (▲), not the keep
|
|
|
|
|
|
// paths' by-design info (○).
|
|
|
|
|
|
let flash = header_flash.current();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
flash.as_deref().is_some_and(|f| {
|
|
|
|
|
|
f.contains('▲') && f.contains("feature") && f.contains("could not remove")
|
|
|
|
|
|
}),
|
|
|
|
|
|
"the revert flashes the reason in the header: {flash:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
// `flash_header`'s repaint (which also re-shows the reverted row) is queued.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(rx.try_recv(), Ok(skim::prelude::Event::Render)),
|
|
|
|
|
|
"the revert queues a repaint"
|
|
|
|
|
|
);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `removal_failure_subject` prefers the branch name (falling back to the
|
|
|
|
|
|
/// worktree path for a detached worktree) and pairs it with the right noun:
|
|
|
|
|
|
/// `worktree` for a worktree removal, `branch` for a branch-only deletion.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_removal_failure_subject() {
|
|
|
|
|
|
let branched = RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path: std::path::PathBuf::from("/tmp/main"),
|
|
|
|
|
|
worktree_path: std::path::PathBuf::from("/tmp/wt-feature"),
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: Some("feature".to_string()),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
super::removal_failure_subject(&branched),
|
|
|
|
|
|
("feature".to_string(), "worktree")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let detached = RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path: std::path::PathBuf::from("/tmp/main"),
|
|
|
|
|
|
worktree_path: std::path::PathBuf::from("/tmp/wt-detached"),
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: None,
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
super::removal_failure_subject(&detached),
|
|
|
|
|
|
("/tmp/wt-detached".to_string(), "worktree")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let branch_only = RemoveResult::BranchOnly {
|
|
|
|
|
|
branch_name: "orphan".to_string(),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
pruned: false,
|
|
|
|
|
|
target_branch: None,
|
|
|
|
|
|
integration_reason: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
super::removal_failure_subject(&branch_only),
|
|
|
|
|
|
("orphan".to_string(), "branch")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// End-to-end through `apply`: `prepare_removal` passes (the worktree is
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// clean and removable), but the background `do_removal` fails on an
|
|
|
|
|
|
/// approved-yet-failing `pre-remove` hook. The row is dropped optimistically,
|
|
|
|
|
|
/// then restored when the removal fails — the worktree is preserved and the
|
|
|
|
|
|
/// list reflects that, instead of leaving a phantom-removed row.
|
|
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_restores_row_when_removal_fails() {
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let wt_path = wt_dir.path().join("feature");
|
|
|
|
|
|
repo.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
wt_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// A `pre-remove` hook that always fails, in the project config the
|
|
|
|
|
|
// picker removal resolves against.
|
|
|
|
|
|
fs::create_dir_all(test.path().join(".config")).unwrap();
|
|
|
|
|
|
fs::write(
|
|
|
|
|
|
test.path().join(".config/wt.toml"),
|
|
|
|
|
|
"pre-remove = \"false\"\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Approve `false` so the hook is selected into the read-only plan and
|
|
|
|
|
|
// actually runs; an isolated approvals path keeps real config untouched.
|
|
|
|
|
|
let pid = repo.project_identifier().unwrap();
|
|
|
|
|
|
let approvals_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let approvals_path = approvals_dir.path().join("approvals.toml");
|
|
|
|
|
|
let mut approvals = Approvals::default();
|
|
|
|
|
|
approvals
|
|
|
|
|
|
.approve_command(pid, "false".to_string(), &approvals_path)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Build the row from the git-reported worktree path, not the raw temp
|
|
|
|
|
|
// path: on macOS `git worktree list` resolves the `/var`→`/private/var`
|
|
|
|
|
|
// symlink, and `prepare_removal`'s path lookup matches that resolved
|
|
|
|
|
|
// form.
|
|
|
|
|
|
let reported_path = repo
|
|
|
|
|
|
.list_worktrees()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|wt| wt.branch.as_deref() == Some("feature"))
|
|
|
|
|
|
.map(|wt| wt.path.clone())
|
|
|
|
|
|
.expect("feature worktree is listed");
|
|
|
|
|
|
let item = branched_picker_item("feature", &reported_path);
|
|
|
|
|
|
let token = item.output().to_string();
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
|
|
|
|
|
let stashed: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = AltXRemover {
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
items: Arc::clone(&items),
|
|
|
|
|
|
repo: repo.clone(),
|
|
|
|
|
|
approvals: Arc::new(approvals),
|
|
|
|
|
|
render_tx: Arc::new(OnceLock::new()),
|
|
|
|
|
|
stashed_warnings: Arc::clone(&stashed),
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
|
|
|
|
|
layout_slot: Arc::new(Mutex::new(None)),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
header_flash: Arc::new(super::items::HeaderFlash::default()),
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.apply(token.clone());
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
// The background removal fails on the approved-yet-failing hook, so
|
|
|
|
|
|
// `restore_failed_removal` runs: only that path stashes a warning, so
|
|
|
|
|
|
// poll on it as the synchronization point.
|
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
|
|
|
|
while stashed.lock().unwrap().is_empty() && Instant::now() < deadline {
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
|
}
|
|
|
|
|
|
let warnings = stashed.lock().unwrap().clone();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
warnings.iter().any(|w| w.contains("feature")),
|
|
|
|
|
|
"a failed removal stashes a `kept` warning: {warnings:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let outputs: Vec<String> = items
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|item| item.output().into_owned())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
outputs,
|
|
|
|
|
|
vec![token],
|
|
|
|
|
|
"the row is restored after the removal fails"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
reported_path.exists(),
|
|
|
|
|
|
"the worktree is preserved when removal fails"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// End-to-end through `apply`: alt-x on a worktree whose branch is unmerged
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// morphs the row to `/ branch` in place. The worktree is removed but the
|
|
|
|
|
|
/// branch is kept (`SafeDelete` won't delete unmerged work), and the row
|
|
|
|
|
|
/// never leaves its slot — its `morphed` flag flips, its `output()` becomes
|
|
|
|
|
|
/// the bare branch token, and its display line is rewritten (no longer the
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// `+ worktree` line). The morph is applied synchronously in `apply`; only
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// the git removal runs on the background thread.
|
|
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_morphs_unmerged_worktree_to_branch_row() {
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
use std::sync::atomic::Ordering;
|
|
|
|
|
|
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let wt_path = wt_dir.path().join("feature");
|
|
|
|
|
|
repo.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
wt_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Make `feature` unmerged: a commit on it that main doesn't have, so
|
|
|
|
|
|
// SafeDelete retains the branch when the worktree is removed.
|
|
|
|
|
|
fs::write(wt_path.join("new.txt"), "unmerged work").unwrap();
|
|
|
|
|
|
worktrunk::shell_exec::Cmd::new("git")
|
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
|
.run()
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
worktrunk::shell_exec::Cmd::new("git")
|
|
|
|
|
|
.args(["commit", "-m", "unmerged work"])
|
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
|
.run()
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Build the row from the git-reported path (macOS resolves the
|
|
|
|
|
|
// `/var`→`/private/var` symlink, which `prepare_removal`'s lookup matches).
|
|
|
|
|
|
let reported_path = repo
|
|
|
|
|
|
.list_worktrees()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|wt| wt.branch.as_deref() == Some("feature"))
|
|
|
|
|
|
.map(|wt| wt.path.clone())
|
|
|
|
|
|
.expect("feature worktree is listed");
|
|
|
|
|
|
let items = Arc::new(Mutex::new(Vec::new()));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::clone(&items), repo.clone());
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
let (row, token, rendered, morphed) =
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
setup_morphable_row(&remover, "feature", &reported_path);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
items.lock().unwrap().push(Arc::clone(&row));
|
|
|
|
|
|
let original_line = rendered.lock().unwrap().clone();
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.apply(token.clone());
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// The morph is synchronous, so it's already applied when `apply` returns:
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// the row is now a branch row in place — flag flipped, token rebranded,
|
|
|
|
|
|
// line rewritten.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
morphed.load(Ordering::Relaxed),
|
|
|
|
|
|
"the kept-branch worktree row is morphed to a branch row"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
row.output().as_ref(),
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
"the morphed row's selection token is the bare branch name"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_ne!(
|
|
|
|
|
|
*rendered.lock().unwrap(),
|
|
|
|
|
|
original_line,
|
|
|
|
|
|
"the morphed row's display line is rewritten to the `/ branch` line"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// The worktree removal itself runs in the background.
|
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
|
|
|
|
while reported_path.exists() && Instant::now() < deadline {
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(!reported_path.exists(), "the worktree is removed");
|
2026-07-10 04:27:29 -07:00
|
|
|
|
// The unmerged branch is retained after its worktree is removed.
|
|
|
|
|
|
await_branch_presence(&repo, "feature", true);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
// The removal succeeded, so the morph stands (no revert).
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
morphed.load(Ordering::Relaxed),
|
|
|
|
|
|
"a successful removal leaves the row morphed"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
/// A kept-branch worktree removal whose row carries no `MorphHandle` (or whose
|
|
|
|
|
|
/// layout hasn't landed) can't morph in place, so `morph_and_remove_in_background`
|
|
|
|
|
|
/// falls back to the drop path: `apply` reports `Dropped` and the row leaves the
|
|
|
|
|
|
/// list (the worktree still removes, the branch is still kept). Same setup as
|
|
|
|
|
|
/// `test_apply_morphs_…` but without `setup_morphable_row`, so the shortcut table
|
|
|
|
|
|
/// has no morph handle for the row.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_apply_drops_unmorphable_kept_branch_row() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let wt_path = wt_dir.path().join("feature");
|
|
|
|
|
|
repo.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
wt_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
// Make `feature` unmerged so SafeDelete keeps the branch (the morph premise).
|
|
|
|
|
|
fs::write(wt_path.join("new.txt"), "unmerged work").unwrap();
|
|
|
|
|
|
worktrunk::shell_exec::Cmd::new("git")
|
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
|
.run()
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
worktrunk::shell_exec::Cmd::new("git")
|
|
|
|
|
|
.args(["commit", "-m", "unmerged work"])
|
|
|
|
|
|
.current_dir(&wt_path)
|
|
|
|
|
|
.run()
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let reported_path = repo
|
|
|
|
|
|
.list_worktrees()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|wt| wt.branch.as_deref() == Some("feature"))
|
|
|
|
|
|
.map(|wt| wt.path.clone())
|
|
|
|
|
|
.expect("feature worktree is listed");
|
|
|
|
|
|
let item = branched_picker_item("feature", &reported_path);
|
|
|
|
|
|
let token = item.output().to_string();
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
|
|
|
|
|
// `test_remover` registers no morph handle, so the kept-branch removal falls
|
|
|
|
|
|
// back to a drop.
|
|
|
|
|
|
let remover = test_remover(Arc::clone(&items), repo.clone());
|
|
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(remover.apply(token), RemovalEffect::Dropped),
|
|
|
|
|
|
"an unmorphable kept-branch removal falls back to the drop path"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
items.lock().unwrap().is_empty(),
|
|
|
|
|
|
"the row drops when it can't morph"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// The worktree is removed in the background; the unmerged branch is kept.
|
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
|
|
|
|
while reported_path.exists() && Instant::now() < deadline {
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(!reported_path.exists(), "the worktree is removed");
|
2026-07-10 04:27:29 -07:00
|
|
|
|
// The unmerged branch is retained.
|
|
|
|
|
|
await_branch_presence(&repo, "feature", true);
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The negative of the above, end-to-end through `apply`: alt-x on a worktree
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// whose branch is *integrated* deletes both the worktree and the branch, so
|
|
|
|
|
|
/// there's no branch to keep — the row drops (it's removed from the list)
|
|
|
|
|
|
/// rather than morphing. `worktree_removal_keeps_branch` returns `None`, so the
|
|
|
|
|
|
/// drop path runs, not the morph. Guards against morphing (and resurrecting) a
|
|
|
|
|
|
/// row whose branch is actually gone.
|
|
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_drops_integrated_worktree_row() {
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
let wt_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let wt_path = wt_dir.path().join("feature");
|
|
|
|
|
|
// No extra commit → `feature` sits at main's commit (integrated), so
|
|
|
|
|
|
// SafeDelete deletes the branch along with the worktree.
|
|
|
|
|
|
repo.run_command(&[
|
|
|
|
|
|
"worktree",
|
|
|
|
|
|
"add",
|
|
|
|
|
|
"-b",
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
wt_path.to_str().unwrap(),
|
|
|
|
|
|
])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let reported_path = repo
|
|
|
|
|
|
.list_worktrees()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|wt| wt.branch.as_deref() == Some("feature"))
|
|
|
|
|
|
.map(|wt| wt.path.clone())
|
|
|
|
|
|
.expect("feature worktree is listed");
|
|
|
|
|
|
let item = branched_picker_item("feature", &reported_path);
|
|
|
|
|
|
let token = item.output().to_string();
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::clone(&items), repo.clone());
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.apply(token);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
|
|
|
|
|
// The drop is synchronous (the row is removed before the background git
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// work), so the list is already empty when `apply` returns.
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
assert!(
|
|
|
|
|
|
items.lock().unwrap().is_empty(),
|
|
|
|
|
|
"the integrated worktree row drops instead of morphing"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-10 04:27:29 -07:00
|
|
|
|
// The background removal deletes both the worktree and the branch
|
|
|
|
|
|
// (nothing to keep).
|
|
|
|
|
|
await_branch_presence(&repo, "feature", false);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
assert!(!reported_path.exists(), "the worktree is removed");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `worktree_removal_keeps_branch` predicts the morph: a `RemovedWorktree`
|
|
|
|
|
|
/// whose `SafeDelete` would retain the branch (unmerged) yields the branch
|
|
|
|
|
|
/// name; an integrated one (deletes the branch) and a force-delete both yield
|
|
|
|
|
|
/// `None`. Built from real refs so the prediction runs the same
|
|
|
|
|
|
/// `integration_reason` the actual delete does.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_worktree_removal_keeps_branch() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
repo.run_command(&["branch", "integrated"]).unwrap();
|
|
|
|
|
|
// `unmerged` carries a commit main lacks.
|
|
|
|
|
|
repo.run_command(&["checkout", "-b", "unmerged"]).unwrap();
|
|
|
|
|
|
fs::write(test.path().join("new.txt"), "work").unwrap();
|
|
|
|
|
|
repo.run_command(&["add", "."]).unwrap();
|
|
|
|
|
|
repo.run_command(&["commit", "-m", "work"]).unwrap();
|
|
|
|
|
|
repo.run_command(&["checkout", "main"]).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let result = |branch: &str, mode| RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path: test.path().to_path_buf(),
|
|
|
|
|
|
worktree_path: test.path().join("gone"),
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: Some(branch.to_string()),
|
|
|
|
|
|
deletion_mode: mode,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
super::worktree_removal_keeps_branch(
|
|
|
|
|
|
&repo,
|
|
|
|
|
|
&result("unmerged", BranchDeletionMode::SafeDelete)
|
|
|
|
|
|
)
|
|
|
|
|
|
.as_deref(),
|
|
|
|
|
|
Some("unmerged"),
|
|
|
|
|
|
"an unmerged branch is kept, so the row morphs"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
super::worktree_removal_keeps_branch(
|
|
|
|
|
|
&repo,
|
|
|
|
|
|
&result("integrated", BranchDeletionMode::SafeDelete)
|
|
|
|
|
|
),
|
|
|
|
|
|
None,
|
|
|
|
|
|
"an integrated branch is deleted, so the row drops"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
super::worktree_removal_keeps_branch(
|
|
|
|
|
|
&repo,
|
|
|
|
|
|
&result("unmerged", BranchDeletionMode::ForceDelete)
|
|
|
|
|
|
),
|
|
|
|
|
|
None,
|
|
|
|
|
|
"force-delete removes even an unmerged branch, so the row drops"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `build_morph_branch_row` renders the `/ branch` line a morph swaps in: the
|
|
|
|
|
|
/// worktree row's model demoted to a local branch on the live layout — gutter
|
|
|
|
|
|
/// `/`, no path — and a `LocalContent` whose `working_tree` reads empty (no
|
|
|
|
|
|
/// worktree to diff), which dims the `working_tree` preview tab.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_build_morph_branch_row() {
|
|
|
|
|
|
use ansi_str::AnsiStr;
|
|
|
|
|
|
|
|
|
|
|
|
let mut worktree_item = ListItem::new_branch("abc123".to_string(), "feature".to_string());
|
|
|
|
|
|
worktree_item.kind = ItemKind::Worktree(Box::new(WorktreeData {
|
|
|
|
|
|
path: Path::new("/tmp/wt.feature").to_path_buf(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}));
|
|
|
|
|
|
let layout = crate::commands::list::layout::calculate_layout_with_width(
|
|
|
|
|
|
std::slice::from_ref(&worktree_item),
|
perf(list): plan background tasks from the columns being rendered (#3274)
## Problem
`[list] columns` filtered purely at the layout layer. A narrowed
selection like `columns = ["branch", "path"]` hid the unselected columns
but still ran every per-worktree git task — `git status`, working/branch
diffs, ahead/behind walks, merge-conflict probes — then threw the
results away. On the kind of repo that motivated #3133 (27 dirty
worktrees) that discarded work is the bulk of the wall-clock cost, so a
"just branch and path" view was no faster than the full table.
This was flagged in the [trace-based diagnosis on the
issue](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4816169750):
`columns = ["branch","path"]` and the default set produced an
**identical** command list. @max-sixty
[confirmed](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4819594461)
it's a bug and asked for the fix.
## Solution
`wt list` decides which background tasks to run in **one canonical
stage**, driven by the columns it will render. The plan flows through
the whole pipeline as a **positive set of tasks to run** — no skip-list,
no inversion, no blanket default.
`collect` computes `tasks` = the union of each rendered column's
`required_tasks()`, gated by the conditions that turn a column off
(`--full`, `[list] summary` + `[commit.generation]`, a url template).
The spawn loop fires exactly that set; the layout renders exactly the
columns it feeds. The rendered set is the `[list] columns` selection for
the table; the picker and `--format json` plan from every column,
because their consumers — the picker's preview tabs, JSON's every-field
contract — need the full data set, not just what renders.
This started as additive pruning layered on the old `skip_tasks`
denylist; review (thanks @max-sixty) pushed it to the canonical,
positive form:
- **One column→task map.** `ColumnSpec::requires_task` is deleted;
`ColumnKind::required_tasks()` is the single source, driving both the
spawn plan and the layout visibility filter (`renders_given_run` — a
column renders iff one of its tasks is in the plan). The two maps can no
longer drift, so the reconciliation test is gone; the `cover_every_task`
drift guard stays and gains teeth (an unconsumed task would never run,
not merely be computed and discarded).
- **A positive run set, end to end.** `CollectOptions` carries `tasks`
(the run set), not a skip set — `collect` threads the plan straight into
the spawn loops, the layout, and `max_pr_number` with no complement
step.
- **No blanket default.** `CollectOptions::for_columns(columns, gates)`
derives the plan; nothing hand-writes a task set. The statusline
declares what it renders (the full column set under full gates, no LLM
summary) instead of leaning on "default everything". The picker rides
`show_full` on `ShowConfig::Resolved`.
- **One mechanism for the summary.** The per-item `SummaryGenerate &&
llm.is_none()` spawn guard is dropped: the column plan is the single
authority on whether the summary runs, and `SummaryGenerateTask` already
returns a clean error on a missing command.
A branch/path `ls` alias over many dirty worktrees now runs no `git
status`, diffs, or ahead/behind walks (#3133), while a column gated off
elsewhere stays off. Behaviour is otherwise unchanged across
default/selection × full/non-full × table/JSON/picker/statusline — no
rendered-output snapshots move (the `help_config_*` snapshots move only
from the columns-doc rewrite).
## Testing
- Planner + filter units: `test_required_tasks_for_render` (the default
set needs every task; a branch/path or custom-column view needs none;
`Status` pulls in every status-feeding task; the gates drop
`ci`/`url`/`summary` even when those columns are explicitly selected)
and `test_renders_given_run` (the "render iff a task is planned" filter,
including `Status` surviving while any signal runs).
- `test_required_tasks_cover_every_task` drift guard retained: the union
of `required_tasks()` across all built-ins equals the full `TaskKind`
set, so no task can fall out of the now-load-bearing map.
- End-to-end: `test_list_config_columns_prune_unused_tasks` (default set
runs `git status --porcelain`; `columns = ["branch", "age"]` runs none)
and `test_list_json_ignores_columns_selection` (`--format json` emits
every field regardless of selection).
- Reviewed by independent finder passes (line-by-line +
removed-behavior, cross-file + picker/JSON equivalence, altitude +
conventions) — no findings; each confirmed the task set is preserved
bit-for-bit. Full `pre-merge` gate (all suites, fmt, clippy, docs-sync,
PTY picker snapshots) green after merging `main`.
Closes #3133
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-06-28 12:05:24 -07:00
|
|
|
|
&crate::commands::list::columns::all_tasks(),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
80,
|
|
|
|
|
|
Path::new("/test"),
|
|
|
|
|
|
None,
|
|
|
|
|
|
None,
|
|
|
|
|
|
crate::commands::list::layout::ColumnSelection {
|
|
|
|
|
|
custom: &[],
|
|
|
|
|
|
selected: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let (line, local) = super::build_morph_branch_row(&layout, &worktree_item, Some("main"));
|
|
|
|
|
|
let plain = line.ansi_strip();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
plain.trim_start().starts_with('/'),
|
|
|
|
|
|
"the morphed line leads with the local-branch gutter `/`: {plain:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
plain.contains("feature"),
|
|
|
|
|
|
"the morphed line shows the branch name: {plain:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!plain.contains("/tmp/wt.feature"),
|
|
|
|
|
|
"the morphed branch row has no worktree path: {plain:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
local,
|
|
|
|
|
|
LocalContent::from_item(&{
|
|
|
|
|
|
let mut b = ListItem::new_branch("abc123".to_string(), "feature".to_string());
|
|
|
|
|
|
b.kind = ItemKind::Branch(BranchScope::Local);
|
|
|
|
|
|
b
|
|
|
|
|
|
}),
|
|
|
|
|
|
"the morphed row's diff signals are the branch's (working_tree empty)"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `removal_target_still_present` observes reality: a worktree dir or a branch
|
|
|
|
|
|
/// ref that's gone reads as removed; one still on disk / in the ref store reads
|
|
|
|
|
|
/// as present (the restore trigger).
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_removal_target_still_present() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let worktree_result = |path: std::path::PathBuf| RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path: test.path().to_path_buf(),
|
|
|
|
|
|
worktree_path: path,
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: Some("x".to_string()),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(super::removal_target_still_present(
|
|
|
|
|
|
&repo,
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
&worktree_result(test.path().to_path_buf()) // still on disk
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
));
|
|
|
|
|
|
assert!(!super::removal_target_still_present(
|
|
|
|
|
|
&repo,
|
|
|
|
|
|
&worktree_result(test.path().join("does-not-exist"))
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
repo.run_command(&["branch", "live-branch"]).unwrap();
|
|
|
|
|
|
let present_branch = RemoveResult::BranchOnly {
|
|
|
|
|
|
branch_name: "live-branch".to_string(),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
pruned: false,
|
|
|
|
|
|
target_branch: None,
|
|
|
|
|
|
integration_reason: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(super::removal_target_still_present(&repo, &present_branch));
|
|
|
|
|
|
|
|
|
|
|
|
let gone_branch = RemoveResult::BranchOnly {
|
|
|
|
|
|
branch_name: "no-such-branch".to_string(),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
pruned: false,
|
|
|
|
|
|
target_branch: None,
|
|
|
|
|
|
integration_reason: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(!super::removal_target_still_present(&repo, &gone_branch));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `removal_will_remove_target` predicts removal from the prepared result
|
|
|
|
|
|
/// alone: a worktree always removes (it passed `ensure_clean`); a branch-only
|
|
|
|
|
|
/// row removes only when the branch is integrated or force-deleted, and never
|
|
|
|
|
|
/// under `Keep` — mirroring `delete_branch_if_safe` so the up-front prediction
|
|
|
|
|
|
/// can't drift from what `do_removal` does.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_removal_will_remove_target() {
|
|
|
|
|
|
use worktrunk::git::IntegrationReason;
|
|
|
|
|
|
|
|
|
|
|
|
let branch_only = |mode: BranchDeletionMode, integration: Option<IntegrationReason>| {
|
|
|
|
|
|
RemoveResult::BranchOnly {
|
|
|
|
|
|
branch_name: "b".to_string(),
|
|
|
|
|
|
deletion_mode: mode,
|
|
|
|
|
|
pruned: false,
|
|
|
|
|
|
target_branch: None,
|
|
|
|
|
|
integration_reason: integration,
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let worktree = RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path: std::path::PathBuf::from("/repo"),
|
|
|
|
|
|
worktree_path: std::path::PathBuf::from("/repo.feature"),
|
|
|
|
|
|
changed_directory: false,
|
|
|
|
|
|
branch_name: Some("feature".to_string()),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
super::removal_will_remove_target(&worktree),
|
|
|
|
|
|
"a worktree removal always drops the row"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
super::removal_will_remove_target(&branch_only(
|
|
|
|
|
|
BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
Some(IntegrationReason::SameCommit)
|
|
|
|
|
|
)),
|
|
|
|
|
|
"an integrated branch is safe-deleted"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!super::removal_will_remove_target(&branch_only(BranchDeletionMode::SafeDelete, None)),
|
|
|
|
|
|
"an unmerged branch is kept, so the row stays"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
super::removal_will_remove_target(&branch_only(BranchDeletionMode::ForceDelete, None)),
|
|
|
|
|
|
"force-delete removes even an unmerged branch"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!super::removal_will_remove_target(&branch_only(
|
|
|
|
|
|
BranchDeletionMode::Keep,
|
|
|
|
|
|
Some(IntegrationReason::SameCommit)
|
|
|
|
|
|
)),
|
|
|
|
|
|
"Keep never deletes, even when integrated"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// `removal_targets_current_worktree` fires only for a `RemovedWorktree` whose
|
|
|
|
|
|
/// `changed_directory` flag is set (the worktree the picker was launched from);
|
|
|
|
|
|
/// a non-current worktree and any `BranchOnly` row read as `false`.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_removal_targets_current_worktree() {
|
|
|
|
|
|
let path = std::path::PathBuf::from("/repo.feature");
|
|
|
|
|
|
let worktree = |changed_directory| RemoveResult::RemovedWorktree {
|
|
|
|
|
|
main_path: std::path::PathBuf::from("/repo"),
|
|
|
|
|
|
worktree_path: path.clone(),
|
|
|
|
|
|
changed_directory,
|
|
|
|
|
|
branch_name: Some("feature".to_string()),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
target_branch: Some("main".to_string()),
|
|
|
|
|
|
force_worktree: false,
|
|
|
|
|
|
removed_commit: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
super::removal_targets_current_worktree(&worktree(true)),
|
|
|
|
|
|
"removing the worktree the picker was launched from"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!super::removal_targets_current_worktree(&worktree(false)),
|
|
|
|
|
|
"removing some other worktree"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!super::removal_targets_current_worktree(&RemoveResult::BranchOnly {
|
|
|
|
|
|
branch_name: "feature".to_string(),
|
|
|
|
|
|
deletion_mode: BranchDeletionMode::SafeDelete,
|
|
|
|
|
|
pruned: false,
|
|
|
|
|
|
target_branch: None,
|
|
|
|
|
|
integration_reason: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
"a branch-only row has no worktree to be standing in"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `keep_current_worktree_row` keeps the row in place and stashes the
|
|
|
|
|
|
/// can't-remove-current-worktree info + switch-away hint — alt-x on the current
|
|
|
|
|
|
/// worktree never removes it and never spawns a background removal.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_keep_current_worktree_row() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let item = branched_picker_item("current", &test.path().join("current"));
|
|
|
|
|
|
let token = item.output().to_string();
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = test_remover(Arc::clone(&items), repo.clone());
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.keep_current_worktree_row();
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
items
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|item| item.output().into_owned())
|
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
|
vec![token.clone()],
|
|
|
|
|
|
"the current worktree row is kept, not removed"
|
|
|
|
|
|
);
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let warnings = remover.stashed_warnings.lock().unwrap().clone();
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
assert!(
|
|
|
|
|
|
warnings.iter().any(|w| w.contains("current worktree")),
|
|
|
|
|
|
"stashes the can't-remove-current-worktree info: {warnings:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
warnings
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|w| w.contains("Switch to another worktree")),
|
|
|
|
|
|
"stashes the switch-away hint: {warnings:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// A second alt-x on the same kept row dedups — the stash doesn't grow.
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.keep_current_worktree_row();
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
assert_eq!(
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.stashed_warnings.lock().unwrap().len(),
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
warnings.len(),
|
|
|
|
|
|
"repeated alt-x on the current worktree stashes the hint only once"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 20:56:08 -07:00
|
|
|
|
/// The header flash a declined alt-x sets self-clears after the beat: the timer
|
|
|
|
|
|
/// thread `flash_header` spawns runs `clear_if_current` + repaints once
|
|
|
|
|
|
/// `HEADER_FLASH_DURATION` elapses. Also pins the keep-path symbol — a by-design
|
|
|
|
|
|
/// decline flashes as info (○), not a warning. Polls for the clear (driving the
|
|
|
|
|
|
/// detached timer to completion) rather than racing a fixed sleep.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_header_flash_set_then_self_clears() {
|
|
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let item = branched_picker_item("current", &test.path().join("current"));
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
|
|
|
|
|
// A live sender so `flash_header` sets the flash and its timer can repaint.
|
|
|
|
|
|
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
|
|
|
|
|
|
Arc::new(OnceLock::new());
|
|
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
|
|
|
|
|
render_tx.set(tx).unwrap();
|
|
|
|
|
|
let header_flash = Arc::new(super::items::HeaderFlash::default());
|
|
|
|
|
|
let remover = AltXRemover {
|
|
|
|
|
|
items,
|
|
|
|
|
|
repo,
|
|
|
|
|
|
approvals: Arc::new(Approvals::default()),
|
|
|
|
|
|
render_tx: Arc::clone(&render_tx),
|
|
|
|
|
|
stashed_warnings: Arc::new(Mutex::new(Vec::new())),
|
|
|
|
|
|
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
|
|
|
|
|
layout_slot: Arc::new(Mutex::new(None)),
|
|
|
|
|
|
header_flash: Arc::clone(&header_flash),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
remover.keep_current_worktree_row();
|
|
|
|
|
|
|
|
|
|
|
|
// The flash is up, styled as info (○ — a by-design decline, not a warning),
|
|
|
|
|
|
// and `flash_header` queued a repaint.
|
|
|
|
|
|
let flash = header_flash.current();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
flash
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.is_some_and(|f| f.contains('○') && f.contains("current worktree")),
|
|
|
|
|
|
"the decline flashes as info in the header: {flash:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(rx.try_recv().is_ok(), "flash_header queues a repaint");
|
|
|
|
|
|
|
|
|
|
|
|
// The timer clears it after the beat. Poll (don't fixed-sleep) so the test
|
|
|
|
|
|
// tracks the detached thread's completion causally; the deadline is a safety
|
|
|
|
|
|
// net well above `HEADER_FLASH_DURATION`.
|
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
|
|
|
|
while header_flash.current().is_some() {
|
|
|
|
|
|
assert!(Instant::now() < deadline, "flash never auto-cleared");
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The auto-clear queues its own repaint (sent right after the clear); poll
|
|
|
|
|
|
// briefly so the assertion doesn't race the timer thread's final send.
|
|
|
|
|
|
let repaint_deadline = Instant::now() + Duration::from_secs(2);
|
|
|
|
|
|
let mut saw_repaint = false;
|
|
|
|
|
|
while Instant::now() < repaint_deadline {
|
|
|
|
|
|
if rx.try_recv().is_ok() {
|
|
|
|
|
|
saw_repaint = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(saw_repaint, "the auto-clear queues a repaint");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
/// alt-x on an unremovable target surfaces the same diagnostic `wt remove`
|
|
|
|
|
|
/// prints rather than swallowing it: `prepare_removal` errors (here the main
|
|
|
|
|
|
/// worktree can't be removed), so the dispatch's `Err` arm stashes the rendered
|
|
|
|
|
|
/// reason and keeps the row in place — no silent dead keypress.
|
|
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_surfaces_unremovable_diagnostic() {
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// The repo root is the main worktree — `prepare_worktree_removal` rejects it.
|
|
|
|
|
|
let item = branched_picker_item("main", test.path());
|
|
|
|
|
|
let token = item.output().to_string();
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
2026-06-30 20:56:08 -07:00
|
|
|
|
// A live sender so the Err arm's `flash_header` sets the header flash.
|
|
|
|
|
|
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
|
|
|
|
|
|
Arc::new(OnceLock::new());
|
|
|
|
|
|
let (tx, _rx) = tokio::sync::mpsc::channel(8);
|
|
|
|
|
|
render_tx.set(tx).unwrap();
|
|
|
|
|
|
let header_flash = Arc::new(super::items::HeaderFlash::default());
|
|
|
|
|
|
let remover = AltXRemover {
|
|
|
|
|
|
items: Arc::clone(&items),
|
|
|
|
|
|
repo: repo.clone(),
|
|
|
|
|
|
approvals: Arc::new(Approvals::default()),
|
|
|
|
|
|
render_tx: Arc::clone(&render_tx),
|
|
|
|
|
|
stashed_warnings: Arc::new(Mutex::new(Vec::new())),
|
|
|
|
|
|
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
|
|
|
|
|
layout_slot: Arc::new(Mutex::new(None)),
|
|
|
|
|
|
header_flash: Arc::clone(&header_flash),
|
|
|
|
|
|
};
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.apply(token.clone());
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
|
|
|
|
|
|
// Nothing was removed, so the row stays...
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
items
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|item| item.output().into_owned())
|
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
|
vec![token],
|
|
|
|
|
|
"an unremovable row is never dropped"
|
|
|
|
|
|
);
|
|
|
|
|
|
// ...and the reason is surfaced, not swallowed.
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let warnings = remover.stashed_warnings.lock().unwrap().clone();
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
assert!(
|
|
|
|
|
|
warnings
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|w| w.contains("main worktree cannot be removed")),
|
|
|
|
|
|
"the unremovable diagnostic is stashed for the user: {warnings:?}"
|
|
|
|
|
|
);
|
2026-06-30 20:56:08 -07:00
|
|
|
|
// The terse headline also flashes in the header at alt-x time.
|
|
|
|
|
|
let flash = header_flash.current();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
flash
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.is_some_and(|f| f.contains("main worktree")),
|
|
|
|
|
|
"the unremovable reason flashes in the header: {flash:?}"
|
|
|
|
|
|
);
|
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates
the selected row in place instead of re-collecting the whole list,
declines removals that can't safely happen with an explanation, and
lands the cursor on the right row afterward — including under an active
filter.
## What changes for the user
`alt-x` on a row now behaves by what the removal does:
- **Unmerged worktree** → the row morphs in place to a `/ branch` row
(worktree gone, branch kept); the worktree removal runs in the
background. No flicker, cursor stays put.
- **Merged worktree** → the row drops; the cursor lands on the row that
slid up into its slot.
- **Current worktree (`@`), main, dirty, locked** → kept, with the same
diagnostic `wt remove` prints (drained to stderr on picker exit) instead
of a silent dead keypress or a disruptive cd-home.
Before, `alt-x` re-collected the entire list (a visible flicker, cursor
reset to top), and removing the current worktree forced a `cd` elsewhere
mid-render.
## The cursor fix (last commit)
The post-removal reposition scrolled to the removed row's index in the
full `shared_items` list. Under a fuzzy query, skim's `item_list` is
filtered and reordered, so that index pointed at the wrong row — the
cursor jumped +N rows down (N = filtered-out rows above it), compounding
on a second removal. Reposition is now by **row identity**: it walks
`item_list` to the target row's `output()` token. The drop's landing row
(the display-neighbor) is captured before the reload via a native
`alt-x` binding (`[capture, reload]`); keep/morph/restore land on their
own row's token.
## Reviewing
The bulk is `src/commands/picker/mod.rs`. Key pieces:
- `invoke` dispatch (`removal_targets_current_worktree` →
`worktree_removal_keeps_branch` → `removal_will_remove_target`) decides
keep / morph / drop.
- `morph_and_remove_in_background` does the in-place row morph
(optimistic, reverted if the worktree unexpectedly survives).
- `reposition_cursor_action` / `install_remove_keybinding` are the
identity-based cursor landing.
## Testing
Picker unit tests plus PTY integration tests through real skim,
including the new
`test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row
with filtered-out decoys above it — confirmed to fail on the old
index-based reposition and pass on identity). Multi-row cursor tests
cover both drop and keep paths.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:33:22 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
/// alt-x on an unmerged branch-only row never drops it (no flicker): an
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
/// unmerged branch with no worktree resolves to `BranchOnly` with no
|
|
|
|
|
|
/// integration reason, so `removal_will_remove_target` predicts `SafeDelete`
|
|
|
|
|
|
/// keeps it. Decided synchronously in `invoke` — no background removal — so the
|
|
|
|
|
|
/// row stays and a one-time `kept … branch` hint is stashed. Driven end-to-end.
|
|
|
|
|
|
#[test]
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
fn test_apply_keeps_unmerged_branch_only_row() {
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
let test = worktrunk::testing::TestRepo::with_initial_commit();
|
|
|
|
|
|
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// An unmerged branch (a commit not on main) with no worktree — SafeDelete
|
|
|
|
|
|
// keeps it.
|
|
|
|
|
|
repo.run_command(&["checkout", "-b", "unmerged"]).unwrap();
|
|
|
|
|
|
fs::write(test.path().join("new.txt"), "unmerged work").unwrap();
|
|
|
|
|
|
repo.run_command(&["add", "."]).unwrap();
|
|
|
|
|
|
repo.run_command(&["commit", "-m", "unmerged work"])
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
repo.run_command(&["checkout", "main"]).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let item = branch_only_picker_item("unmerged");
|
|
|
|
|
|
let token = item.output().to_string();
|
|
|
|
|
|
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
|
|
|
|
|
|
let stashed: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
2026-06-30 20:56:08 -07:00
|
|
|
|
// A live sender so `flash_header` sets the header flash rather than
|
|
|
|
|
|
// taking its no-`render_tx` early return.
|
|
|
|
|
|
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
|
|
|
|
|
|
Arc::new(OnceLock::new());
|
|
|
|
|
|
let (tx, _rx) = tokio::sync::mpsc::channel(8);
|
|
|
|
|
|
render_tx.set(tx).unwrap();
|
|
|
|
|
|
let header_flash = Arc::new(super::items::HeaderFlash::default());
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
let remover = AltXRemover {
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
items: Arc::clone(&items),
|
|
|
|
|
|
repo: repo.clone(),
|
|
|
|
|
|
approvals: Arc::new(Approvals::default()),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
render_tx: Arc::clone(&render_tx),
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
stashed_warnings: Arc::clone(&stashed),
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
|
|
|
|
|
layout_slot: Arc::new(Mutex::new(None)),
|
2026-06-30 20:56:08 -07:00
|
|
|
|
header_flash: Arc::clone(&header_flash),
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.apply(token.clone());
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
|
|
|
|
|
// The keep path is synchronous (no background thread), so by the time
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
// `apply` returns the row is still present and the hint is stashed.
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
let outputs: Vec<String> = items
|
|
|
|
|
|
.lock()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|item| item.output().into_owned())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
outputs,
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
vec![token.clone()],
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
"the unmerged branch-only row is never dropped"
|
|
|
|
|
|
);
|
|
|
|
|
|
let warnings = stashed.lock().unwrap().clone();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
warnings
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|w| w.contains("unmerged") && w.contains("retained")),
|
|
|
|
|
|
"a kept unmerged branch stashes a `retained` info line: {warnings:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
warnings.iter().any(|w| w.contains("wt remove -D unmerged")),
|
|
|
|
|
|
"a kept unmerged branch stashes the actionable `-D` hint: {warnings:?}"
|
|
|
|
|
|
);
|
2026-06-30 20:56:08 -07:00
|
|
|
|
// The *why* also flashes in the header immediately (the in-picker echo of
|
|
|
|
|
|
// the stash), not only on exit.
|
|
|
|
|
|
let flash = header_flash.current();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
flash
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.is_some_and(|f| f.contains("Kept") && f.contains("unmerged")),
|
|
|
|
|
|
"the keep path flashes the reason in the header: {flash:?}"
|
|
|
|
|
|
);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
// A second alt-x on the same kept row dedups — the stash doesn't grow.
|
fix(switch): eliminate the alt-x picker cursor flash (#3268)
## What
Removing a row in the `wt switch` picker with `alt-x` flashed the `>`
pointer to the top of the list for a frame before it settled on the
right row. This eliminates that flash.
## Root cause
The flash was structural to skim's `reload`, not a lagging reposition.
`alt-x` was bound to `reload(remove {})`, and skim's `handle_reload`
clears the item pool and restarts the matcher *before* the new rows
stream in — so the matcher runs once against the empty pool, `Replace`s
`item_list` with nothing, and skim's render clamp (`items.is_empty() →
current = 0`) resets the cursor to the top. A `reposition` Custom action
then snapped it back, but the reset frame was already painted.
(`no_clear_if_empty` doesn't help: its protective "keep stale rows"
branch is gated on skim's interactive mode, which the picker doesn't use
— verified with a steady-state A/B.)
## The fix
Replace the `reload`-based removal with a **synchronous in-place pool
resync**. `alt-x` is now a single Custom keybinding callback that runs
the removal dispatch (`AltXRemover::apply`) and rebuilds skim's pool
from the mutated row list (`resync_pool`) on the same event-loop tick.
The pool is filled before the matcher runs, so the matcher only ever
sees the post-removal list — never empty — and `current` is preserved
(clamped to the shrunk list). The row that slides into the removed slot
lands under the cursor for free, with no flash, under a fuzzy filter
too.
This lets the whole reposition apparatus go —
`reposition_cursor_action`, `send_reposition`, the `drop_landing`
neighbour capture, the settle counters — for a net deletion. The removal
dispatch moved off the collector into a `Send` `AltXRemover` (skim
requires the keybinding callback to be `Send`, so it can't carry the
collector's `Rc<PipelineFactory>`); `PickerCollector` now only serves
the `alt-r` refresh. Keep/morph repaint in place; the rare
failed-removal restore queues a `resync_pool_action`.
## Two follow-on fixes surfaced while validating
**Last-row preview.** skim auto-repaints the preview across the
matcher's `Replace` only when the selected row's text changes — which a
last-row drop doesn't produce (`current` goes briefly out of range, then
clamps onto the new last row with no text change). The pane kept showing
the removed row's preview; `run_preview_when_settled` restores the
missing repaint. (Found in review.)
**`--prs` streaming order.** The alt-x rework tipped a pre-existing
microsecond race: `on_skeleton` woke the `--prs` thread
(`grid_slot.set`) *before* sending the skeleton batch, so with a fast
forge call the PR rows could reach skim's channel first and a PR row
would take the reserved header slot (`header_lines(1)`), displacing the
header — the cursor then couldn't reach the PR row. That's what made
`test_switch_picker_preview_auto_refreshes_when_compute_lands` a
long-standing flake. Fixed deterministically: the `--prs` thread is now
woken only after the skeleton is sent, so PR rows always append after
it.
## Navigating the diff
`src/commands/picker/mod.rs` (the alt-x rework, a net deletion),
`progressive_handler.rs` (the one-statement `--prs` ordering fix), and
`tests/integration_tests/switch_picker.rs` (new tests).
## Testing
The eight existing alt-x cursor PTY tests pass unchanged; removal unit
tests drive `AltXRemover::apply` directly. Added PTY tests for the
last-row preview refresh and the failed-removal restore, and a unit test
for the unmorphable-row drop fallback. Verified frame-by-frame under
tmux (drop, morph, keep, filtered drop, last-row drop) that the pointer
never jumps to the top row. The full pre-merge gate passes locally (4255
tests).
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:30:36 -07:00
|
|
|
|
remover.apply(token);
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
stashed.lock().unwrap().clone(),
|
|
|
|
|
|
warnings,
|
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`).
`alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature.
Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:21:55 -07:00
|
|
|
|
"repeated alt-x on the same kept row stashes the hint only once"
|
fix(switch): keep the alt-r picker row when its target isn't actually removed (#3211)
## Summary
In the `wt switch` interactive picker, pressing `alt-r` dropped a row
optimistically and then removed the worktree on a background thread.
When the removal didn't actually remove the target — a worktree that
became dirty, or a branch-only row whose unmerged branch `SafeDelete`
keeps — the worktree/branch was preserved by design (data safety holds),
but the displayed row had already vanished. The list showed a removal
that never happened, and the row that did come back flickered off and
on.
This closes the producer→consumer loop so a row's presence always
matches reality, and decides as much as possible **up front** so the
common cases never flicker.
## Approach
The key observation: `prepare_worktree_removal` — which `invoke` already
calls synchronously on skim's event loop — *already* computes whether a
removal will succeed (`ensure_clean` for worktrees, `integration_reason`
for branch-only rows, from the same `Repository::integration_reason`
that `delete_branch_if_safe` later consults). So the outcome is knowable
before the row is dropped; we don't need to run the removal to find out.
Two complementary mechanisms, duals of each other:
- **`removal_will_remove_target`** (predict, *before* dropping). A
worktree always removes (it passed `ensure_clean`); a branch-only row
removes only when `delete_branch_if_safe` would (integrated, or force,
never `Keep`). `invoke` drops the row and removes in the background only
when removal is predicted. An **unmerged branch-only row** — the one
case that reliably flickered under the optimistic drop — now stays put
and surfaces the canonical `Branch X retained; has unmerged changes` +
`To delete the unmerged branch, run wt remove -D X` info/hint that `wt
remove` itself prints, decided synchronously with no background work.
- **`removal_target_still_present`** + **`restore_failed_removal`**
(observe, *after*). The data-safety backstop for failures the up-front
prediction can't see, because they're genuinely asynchronous: a
clean-check race against `ensure_clean`, a failing approved `pre-remove`
hook, or a branch that raced integrated→unmerged. These are decided from
**primary evidence** — does the worktree directory or branch ref still
exist after `do_removal`? — not from the removal's `Result`, which is
the wrong signal in both directions (a `RemovedWorktree` removal can
return `Err` after the worktree is already trashed when a
`post-remove`/`post-switch` hook fails to render/spawn during the
announcer flush; a refusal returns `Ok` while keeping the target). When
the target survives, the row is re-inserted at its slot, a `Kept X —
could not remove it` warning is stashed, and the picker reloads so the
cursor lands back on the row.
The happy path (successful removal) and the sticky-cursor reposition
from #3199 are untouched.
### Behavior matrix
| `alt-r` on… | target after | row |
|------|----------------------|-----|
| clean worktree (common case) | gone | drops, no flicker |
| worktree, dirty at `alt-r` | present | not dropped (caught by
`prepare`, no flicker) |
| **unmerged branch-only row** | **present** | **stays put, no flicker**
— surfaces `retained; unmerged` |
| branch-only, integrated | gone | drops, no flicker |
| worktree clean→dirty race / failing approved `pre-remove` | present |
dropped → restored (rare async backstop) |
| branch raced integrated→unmerged | present | dropped → restored (rare
async backstop) |
The only residual flicker is the genuinely-rare async races in the last
two rows, which can't be known up front and where restoring is the
data-safety backstop.
## Key files
Most changes are in `src/commands/picker/mod.rs`:
`removal_will_remove_target` (the up-front predictor) and
`removal_target_still_present` (the after-the-fact observer), with
`invoke` dispatching to `drop_and_remove_in_background` or
`keep_unremovable_row`, plus `restore_failed_removal` /
`removal_failure_subject` for the backstop path and a single
`send_reposition` helper for cursor moves. The canonical "retained;
unmerged" message is shared with `wt remove` via
`retained_unmerged_branch_messages` in `src/output/handlers.rs`, so the
two emit paths can't drift.
## Testing
`cargo run -- hook pre-merge --yes` passes (4191 tests, clippy, fmt).
New/updated coverage:
- `test_removal_will_remove_target` — the predictor's matrix (worktree,
integrated/unmerged/force/keep branch-only).
- `test_invoke_keeps_unmerged_branch_only_row` — synchronous keep
through `invoke`: row never dropped, canonical hint stashed, branch
preserved.
- `test_invoke_restores_row_when_removal_fails` — the backstop,
end-to-end through `invoke` via an approved-yet-failing `pre-remove`
hook.
- `test_switch_picker_alt_r_keeps_unmerged_branch_row` — a real-PTY
integration test that filters to an unmerged branch, presses `alt-r`,
and asserts the row survives (the inverse of the existing "removed row
empties the list" test). #3199's sticky-cursor PTY test still passes.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:34:35 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let branch_list = repo.run_command(&["branch", "--list", "unmerged"]).unwrap();
|
|
|
|
|
|
assert!(!branch_list.is_empty(), "the unmerged branch is preserved");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
refactor(picker): unify worktree and PR rows into one PickerRow (#3259)
Collapse the picker's two `SkimItem` types into one. `WorktreeSkimItem`
(a checked-out worktree row) and `PrSkimItem` (a listed `--prs` row)
become a single `PickerRow` whose only branching axis is `local:
Option<LocalCheckout>` — `Some` for a worktree row, `None` for a listed
PR. This is the type-level completion of #3252, which made the two row
kinds behave identically; they now share one `text()`, `display()`,
`output()`, `preview()`, and PR-pane renderer instead of two parallel
implementations.
## What changed
- `PrSkimItem` and its standalone `impl SkimItem` are deleted. Listed
`--prs` rows are built by `prs::listed_pr_row` (shared by
`fetch_and_stream` and the row tests) as `PickerRow { local: None, … }`
with a static `pr_status` slot pre-filled by the new
`PrEntry::display_status()`.
- The worktree-only fields (`has_upstream`, `summaries_enabled`,
`local_content`) move into a `LocalCheckout` sub-struct behind `local`.
The frozen `Arc<ListItem>` handle is gone, replaced by a precomputed
`output_token`.
- `pr_status` and the preview cache are shared by both row kinds, keyed
by `PickerRow::preview_key()` — the branch for a worktree row,
`pr:N`/`mr:N` for a listed PR. A `--prs` row's `pr` pane is memoized in
that session-long cache, so `listed_pr_row` drops the prior `(pr:N, Pr)`
entry on each build; an `alt-r` reload then re-renders the freshly
fetched PR metadata instead of the pre-reload pane (the worktree-row
analog of `on_update`'s invalidation).
## Behavior change
A worktree row tracking a draft PR now shows a `state: draft` line in
its `pr` pane. Previously only `--prs` rows surfaced draft state. This
falls out of both kinds sharing `render_pr_pane_body`.
## Reviewer orientation
- `src/commands/picker/items.rs` — the unified
`PickerRow`/`LocalCheckout`, `preview_key()`, `render_pr_pane_body()`,
`render_listed_pr_mode()`.
- `src/commands/picker/prs.rs` — `PrSkimItem` removed;
`PrEntry::display_status()` and the `listed_pr_row` constructor (with
its cache invalidation).
- `src/commands/picker/progressive_handler.rs` — worktree-row
construction with `local: Some(LocalCheckout { … })`.
- The remaining files are call-site and doc renames.
Rendered output is unchanged — the integration snapshots still pass, and
the `loading_placeholder` snapshots change only their `expression:`
metadata line. Rebased on main's #3253 (the `↳` loading-placeholder
glyph), which is preserved.
> _This was written by Claude Code on behalf of max_
2026-06-26 11:18:38 -07:00
|
|
|
|
// Note: skim's `as_any().downcast_ref::<PickerRow>()` can fail at
|
refactor(picker): migrate to skim 4.8 (ratatui), drop vendored skim-tuikit (#3137)
Migrates the `wt switch` picker from skim 0.20.5 (tuikit backend) to
skim 4.8.0 (ratatui/crossterm), removes the `vendor/skim-tuikit/` patch
tree we carried against the old line, and makes the full test suite
green under the new backend.
## Why the vendor tree goes away
We vendored skim-tuikit for two patches: `alt-<digit>` key parsing and
an `Output::flush` `write_all` fix for dropped bytes under PTY pressure.
Both are moot in 4.8. Key parsing is native — `binds.rs` routes every
single character (digits included) through `KeyCode::Char` rather than a
hardcoded `alt-a..alt-z` arm list. The flush fix is subsumed by stdlib
`BufWriter::flush` in the ratatui backend, which loops over partial
writes correctly. So the migration carries zero vendor patches against
skim, and the build machinery that kept the vendored source alive
(Taskfile `vendor-diff`, flake `vendorSrc`/`extraDummyScript`) is
deleted too.
## The picker rewrite
skim 4.x is a different API. `WorktreeSkimItem::display()` now returns
`ratatui::text::Line` instead of an `AnsiString`. alt-r removal rides
skim's `reload(remove {})` token (the selected row's `output()`,
expanded into the reload command) instead of the old
`as_any().downcast_ref` path (which never worked across compilation
units) plus a signal file. Preview-tab switching and the progressive
list redraw are driven by `Skim::event_sender()` +
`Event::Render`/`Event::RunPreview`.
## The regression that motivated the bulk of this
skim 4.x renders on demand. There is no 100ms tuikit heartbeat
re-rendering the frame, so the progressive `wt switch` list rendered
blank while collection ran in the background — the original report. The
fix pokes `Event::Render` from the list-collect callbacks (throttled to
16ms). The same on-demand model surfaced four more regressions, all
fixed here and verified head-to-head against the 0.20 picker in a
terminal: preview-tab lag, current-row highlight, Shift-Tab back-cycle
(crossterm reports Shift-Tab under three distinct `KeyEvent` shapes, so
all three are bound), and alt-r removal (4.x `execute-silent` is
fire-and-forget and raced its reader).
## Help text and the test harness
Two things the integration suite caught that the unit tests did not (the
prior pass ran `--lib --bins` only):
- **Styled help.** skim 0.20 transitively pulled in
`clap/unstable-markdown`, which renders worktrunk's `///` doc-comments
as styled help. skim 4 with `default-features = false` drops it, so help
reverted to raw text (`[experimental]` → `\[experimental\]`). Restored
by depending on `unstable-markdown` explicitly, keeping help output
identical and the `test_help` / `test_step_alias` /
`test_docs_are_in_sync` snapshots passing unchanged.
- **PTY harness.** skim 4.x queries cursor position (`ESC[6n`) at
startup in partial-height mode and blocks in `select()` for the reply;
`portable_pty` is a bare PTY and never answered, so every
`switch_picker` test failed init with "Cursor position detection timed
out." The Unix harness now answers the query, mirroring the existing
ConPTY responder. skim 4.x also draws the list/preview separator one
column left, so the snapshot panel-split columns shifted to match. The
regenerated picker snapshots show two cosmetic changes: the match
counter no longer overlaps the preview tab header, and the HEAD column
shows the full short-SHA instead of a truncated one.
## Minimal-versions CI
The skim 4.8 dep tree pins tighter floors than our manifests declared,
so the nightly `minimal-versions` job needed updating. It now runs `-Z
direct-minimal-versions` — minimize only our own direct deps and let
transitive crates resolve normally — and the manifests raise each
under-specified floor to the minimum the workspace builds against
(largely mirroring skim 4.8's own requirements). Full `-Z
minimal-versions` would instead drag in skim's transitive TUI/image
stack (ansi-to-tui, ratatui's `instability` macro, color-eyre,
ratatui-image → image/avif), whose crates under-declare their floors and
don't compile at the picked versions; direct minimization confines the
check to floors we own, so no transitive pins are needed. Normal
resolution (the committed `Cargo.lock`) is unchanged. Full floor list
and the `signal-hook` libc-pin detail are in the `ci(min-versions)`
commit message.
## Reviewing
Start at `src/commands/picker/mod.rs` (the `run_skim` entry point, the
action keybinds, and `parse_reload_remove_token`), then
`progressive_handler.rs` (the render pokes) and `items.rs` (the
`Line`-based `display()`). Test-harness changes are in
`tests/common/pty.rs` (the `ESC[6n` responder) and
`tests/integration_tests/switch_picker.rs` (the panel-split columns).
The rest is the dependency swap, the vendor and build-machinery
deletions, and regenerated snapshots.
All 3980 tests pass (`cargo run -- hook pre-merge --yes`), including the
`shell-integration-tests` PTY suite that exercises the picker end-to-end
across the list, previews, scroll, create/remove, and accept flows. No
automated test drives a real terminal; the interactive surface was also
checked by hand against the 0.20 picker.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:41:20 -07:00
|
|
|
|
// runtime due to a TypeId mismatch between skim's reader thread and the main
|
|
|
|
|
|
// compilation unit. The invoke() code path uses output() matching instead.
|
|
|
|
|
|
// Full TUI tests require interactive skim — verified via tmux-cli during
|
|
|
|
|
|
// development.
|
refactor: split large files into focused modules (#688)
* refactor: split large files into focused modules
Split 6 large files (~11,500 lines total) into smaller, focused modules:
- shell.rs (1,709 lines) → src/shell/ (detection, paths, utils)
- ci_status.rs (1,710 lines) → src/commands/list/ci_status/ (cache, github, gitlab, platform)
- worktree.rs (1,632 lines) → src/commands/worktree/ (hooks, push, remove, resolve, switch, types)
- config.rs (1,904 lines) → src/commands/config/ (create, hints, show, state)
- select.rs (1,850 lines) → src/commands/select/ (items, log_formatter, pager, preview)
- collect.rs (2,639 lines) → src/commands/list/collect/ (execution, results, tasks, types)
Also updated .pre-commit-config.yaml exclude patterns to match new module paths.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update intra-doc link for parse_remote_owner
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:15:38 -08:00
|
|
|
|
}
|