From 331e836db60408bbfbed75a94f54081f162be170 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 25 Sep 2026 18:49:05 -0400 Subject: [PATCH] d on an empty worktree band with a detected pull request deletes the worktree, and its right-click opens the worktree's menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An empty band's only row can be its checkout's detected pull request, a link row the grid draws on the band's rule rather than as a card. The cursor rested on it, so d flashed "the pull request link can't be deleted — it comes from git" and the right-click opened the link's menu instead of the worktree's. - empty_band now looks past link rows, and d and the row menu ask it before matching the row under the cursor. - Regression test covers d, a right-click on the band and a right-click on the #N on its rule. Closes #104 Co-Authored-By: Claude Opus 5.5 --- crates/nebula-tui/src/event_loop.rs | 44 ++++----- crates/nebula-tui/src/event_loop/launcher.rs | 94 +++++++++++++++++++- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index e59cc2e6..841addb9 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -4659,20 +4659,20 @@ fn open_delete_confirm(app: &mut App) { activate::delete_worktree(app, &id); } } - Focus::Sessions => match app.selected_session_row() { - Some(SessionRow::Agent(a)) => { - app.overlay = Some(Overlay::Confirm(confirm_delete_agent_in(app, &a))); - } - Some(SessionRow::Terminal(t)) => { - app.overlay = Some(Overlay::Confirm(confirm_close_terminal_in(app, &t))); - } - Some(SessionRow::Link(l)) => delete_link(app, &l), - // An EMPTY BAND on the grid: the worktree is all there is. - None => { - if let Some(id) = launcher::empty_band(app) { - activate::delete_worktree(app, &id); + // An EMPTY BAND on the grid: the worktree is all there is — even + // with its pull request's link row under the cursor (#104). + Focus::Sessions => match launcher::empty_band(app) { + Some(id) => activate::delete_worktree(app, &id), + None => match app.selected_session_row() { + Some(SessionRow::Agent(a)) => { + app.overlay = Some(Overlay::Confirm(confirm_delete_agent_in(app, &a))); } - } + Some(SessionRow::Terminal(t)) => { + app.overlay = Some(Overlay::Confirm(confirm_close_terminal_in(app, &t))); + } + Some(SessionRow::Link(l)) => delete_link(app, &l), + None => {} + }, }, Focus::Terminal => {} } @@ -5244,17 +5244,19 @@ fn context_menu_items(app: &App, focus: Focus) -> Option> { None => app.selected_worktree().map(|w| worktree_menu_items(app, w)), }, }, - Focus::Sessions => match app.selected_session_row() { - Some(SessionRow::Agent(a)) => Some(menu_items_for_session_in(app, &a)), - Some(SessionRow::Terminal(t)) => Some(menu_items_for_terminal(&t)), - Some(SessionRow::Link(l)) => Some(menu_items_for_link(&l)), - // An EMPTY BAND on the grid: its checkout's own menu, the - // same **Delete worktree** its `d` opens. - None => { - let id = launcher::empty_band(app)?; + // An EMPTY BAND on the grid: its checkout's own menu, the same + // **Delete worktree** its `d` opens — its pull request's link row + // under the cursor or not (#104). + Focus::Sessions => match launcher::empty_band(app) { + Some(id) => { let w = app.tree.worktrees.iter().find(|w| w.id == id)?; Some(worktree_menu_items(app, w)) } + None => match app.selected_session_row()? { + SessionRow::Agent(a) => Some(menu_items_for_session_in(app, &a)), + SessionRow::Terminal(t) => Some(menu_items_for_terminal(&t)), + SessionRow::Link(l) => Some(menu_items_for_link(&l)), + }, }, Focus::Terminal => None, } diff --git a/crates/nebula-tui/src/event_loop/launcher.rs b/crates/nebula-tui/src/event_loop/launcher.rs index eb16e6fd..572956fb 100644 --- a/crates/nebula-tui/src/event_loop/launcher.rs +++ b/crates/nebula-tui/src/event_loop/launcher.rs @@ -335,8 +335,16 @@ pub(super) fn land_on_grid(app: &mut App) { /// no card on it, which only **Show all worktrees** draws. What `d` and /// a right-click act on there: the worktree itself, as nothing else is. /// None with the grid down, or the cursor on a card or a band of them. +/// +/// The checkout's link rows are no card: its detected pull request is a +/// row the cursor rests on (`App::visible_session_rows`), but on the grid +/// it is the `#42` on the band's rule, so a band holding it is still +/// empty (#104) — callers ask this before they match the row. pub(super) fn empty_band(app: &App) -> Option { - if !app.launcher_active() || app.selected_session_row().is_some() { + let on_card = app + .selected_session_row() + .is_some_and(|row| row.sref().is_some()); + if !app.launcher_active() || on_card { return None; } let bands = view::bands(app); @@ -3532,6 +3540,90 @@ mod tests { }); } + /// [`with_empty_band`] with `idle`'s branch on a pull request git + /// detected: still no cards on its band, but the checkout's rows now + /// hold the pull request's link row, and the cursor rests on it. + fn with_empty_band_on_a_pull_request() -> App { + let mut app = with_empty_band(); + app.pull_requests + .insert(WorktreeId("w3".into()), Some(pull_request(7))); + app + } + + /// An EMPTY BAND whose checkout has a detected pull request is still + /// an empty band (#104): `d` opens the worktree's confirm its hint + /// promises, not the flash that the pull request's link can't be + /// deleted, and a right-click — on the band, or on the `#7` on its + /// rule — opens the worktree's menu, not the link's. + #[test] + fn an_empty_band_on_a_pull_request_still_deletes_the_worktree() { + with_default_config(|| { + let is_worktree_confirm = |app: &App| { + matches!(&app.overlay, Some(Overlay::Confirm(c)) + if c.action == PendingAction::DeleteWorktree(WorktreeId("w3".into()))) + }; + + let mut by_key = with_empty_band_on_a_pull_request(); + let screen = screen_text(&draw_tall(&mut by_key)); + assert!(screen.contains("d: delete worktree"), "{screen}"); + keys(&mut by_key, &[KeyCode::Char('j'), KeyCode::Char('j')]); + assert_eq!( + by_key.selected_worktree().map(|w| w.id.clone()), + Some(WorktreeId("w3".into())) + ); + assert!( + matches!( + by_key.selected_session_row(), + Some(crate::app::SessionRow::Link(_)) + ), + "the pull request's link row is under the cursor: {:?}", + by_key.selected_session_row() + ); + let sent = key(&mut by_key, KeyCode::Char('d'), KeyModifiers::NONE); + assert!( + is_worktree_confirm(&by_key), + "{:?} / {:?}", + by_key.overlay, + by_key.flash + ); + assert_eq!(by_key.flash, None); + assert!(sent.is_empty(), "asked first: {sent:?}"); + + let mut drawn = with_empty_band_on_a_pull_request(); + draw_tall(&mut drawn); + let band = band_area(&drawn, 2); + let pr = drawn + .hits + .iter() + .find(|(_, hit)| *hit == HitTarget::LauncherBandPr(WorktreeId("w3".into()))) + .map(|(rect, _)| *rect) + .expect("the pull request on the empty band's rule"); + for (x, y) in [(band.x + 4, band.y + 1), (pr.x, pr.y)] { + let mut by_click = with_empty_band_on_a_pull_request(); + draw_tall(&mut by_click); + mouse( + &mut by_click, + MouseEventKind::Down(MouseButton::Right), + x, + y, + ); + let at = match &by_click.overlay { + Some(Overlay::Menu(menu)) => menu + .items + .iter() + .position(|i| i.label == "Delete worktree") + .unwrap_or_else(|| panic!("no Delete worktree in {menu:?}")), + other => panic!("expected the worktree's menu, got {other:?}"), + }; + for _ in 0..at { + key(&mut by_click, KeyCode::Down, KeyModifiers::NONE); + } + key(&mut by_click, KeyCode::Enter, KeyModifiers::NONE); + assert!(is_worktree_confirm(&by_click), "{:?}", by_click.overlay); + } + }); + } + /// With **Show all worktrees** on, deleting a worktree's last card /// never offers the worktree: the card's ordinary confirm, a delete /// of the card alone, and its band stays on the grid — empty, the