From 700f1e4a381f6fbb0c446a4273f9eec6f4e7e2bb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 8 May 2026 10:08:26 -0300 Subject: [PATCH] fix(tui): make current worktree selection a no-op --- codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/worktree.rs | 5 + codex-rs/tui/src/app_event.rs | 5 + ...tui__worktree__tests__worktree_picker.snap | 4 +- codex-rs/tui/src/worktree.rs | 97 ++++++++++++++++--- 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 27035083ba..2672339033 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -215,6 +215,10 @@ impl App { self.begin_switch_to_worktree_target(tui, target); tui.frame_requester().schedule_frame(); } + AppEvent::CurrentWorktreeSelected { target } => { + self.current_worktree_selected(target); + tui.frame_requester().schedule_frame(); + } AppEvent::SwitchToWorktreeAfterLoading { target } => { self.switch_to_worktree_target_after_loading(tui, app_server, target) .await; diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index da15381515..e5d638179c 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -183,6 +183,11 @@ impl App { self.defer_switch_to_worktree_target(target); } + pub(super) fn current_worktree_selected(&mut self, target: String) { + self.chat_widget + .add_info_message(format!("Already in worktree {target}."), /*hint*/ None); + } + pub(super) async fn switch_to_worktree_target_after_loading( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 32900a277a..72f32f1d81 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -224,6 +224,11 @@ pub(crate) enum AppEvent { target: String, }, + /// A picker row for the current worktree was selected. + CurrentWorktreeSelected { + target: String, + }, + /// Continue switching into an existing worktree after the loading view has rendered. SwitchToWorktreeAfterLoading { target: String, diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap index 732e83202c..be9827b7b9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap @@ -7,8 +7,8 @@ expression: "render_selection(params, 86)" Create a worktree or fork this chat into an existing workspace. Search worktrees -› New worktree... Type the branch name for the new worktree. - fcoury/demo (current) clean · cli · /repo/codex.fcoury-demo + New worktree... Create a sibling worktree and start this chat there. +› fcoury/demo (current) Already in this worktree codex clean · app · /repo/codex.codex main dirty · git · /repo/codex.main diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index 23fd17a4af..32a5554f5a 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -14,6 +14,7 @@ use ratatui::widgets::WidgetRef; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionViewParams; @@ -361,7 +362,8 @@ pub(crate) fn error_with_summary_params(summary: String, error: String) -> Selec pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> SelectionViewParams { let mut items = vec![new_worktree_item()]; - items.extend(entries.into_iter().map(|entry| { + let mut initial_selected_idx = None; + items.extend(entries.into_iter().enumerate().map(|(idx, entry)| { let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone()); let source = source_label(entry.source); let status = if entry.dirty.is_dirty() { @@ -369,7 +371,16 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S } else { "clean" }; + let is_current = is_current_worktree(current_cwd, &entry); + if is_current { + initial_selected_idx = Some(idx + 1); + } let description = format!("{status} · {source} · {}", entry.workspace_cwd.display()); + let selected_description = if is_current { + "Already in this worktree".to_string() + } else { + format!("Fork this chat into {}", entry.workspace_cwd.display()) + }; let search_value = Some(format!( "{} {} {} {}", target, @@ -377,21 +388,28 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S source, entry.workspace_cwd.display() )); - SelectionItem { - name: target.clone(), - description: Some(description), - selected_description: Some(format!( - "Fork this chat into {}", - entry.workspace_cwd.display() - )), - is_current: paths_match(current_cwd, &entry.workspace_cwd), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::SwitchToWorktree { - target: target.clone(), + let target_for_action = target.clone(); + let actions: Vec = if is_current { + vec![Box::new(move |tx| { + tx.send(AppEvent::CurrentWorktreeSelected { + target: target_for_action.clone(), }); - })], + })] + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchToWorktree { + target: target_for_action.clone(), + }); + })] + }; + SelectionItem { + name: target, + description: Some(description), + selected_description: Some(selected_description), + actions, dismiss_on_select: true, search_value, + is_current, ..Default::default() } })); @@ -411,6 +429,7 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S search_placeholder: Some("Search worktrees".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, row_display: SelectionRowDisplay::SingleLine, + initial_selected_idx, ..Default::default() } } @@ -560,6 +579,20 @@ fn paths_match(a: &Path, b: &Path) -> bool { a == b } +fn is_current_worktree(current_cwd: &Path, entry: &WorktreeInfo) -> bool { + if paths_match(current_cwd, &entry.workspace_cwd) { + return true; + } + let current_cwd = current_cwd + .canonicalize() + .unwrap_or_else(|_| current_cwd.to_path_buf()); + let worktree_root = entry + .worktree_git_root + .canonicalize() + .unwrap_or_else(|_| entry.worktree_git_root.clone()); + current_cwd.starts_with(worktree_root) +} + #[cfg(test)] mod tests { use super::*; @@ -624,6 +657,44 @@ mod tests { insta::assert_snapshot!("worktree_picker", render_selection(params, /*width*/ 86)); } + #[test] + fn worktree_picker_preselects_current_worktree_from_subdirectory() { + let params = picker_params( + vec![ + sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false), + sample_info( + "fcoury/worktrees", + WorktreeSource::Git, + /*dirty*/ false, + ), + ], + Path::new("/repo/codex.fcoury-worktrees/codex-rs"), + ); + + assert_eq!(params.initial_selected_idx, Some(2)); + } + + #[test] + fn current_worktree_item_dispatches_current_selection_event() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = picker_params( + vec![sample_info( + "fcoury/worktrees", + WorktreeSource::Git, + /*dirty*/ false, + )], + Path::new("/repo/codex.fcoury-worktrees/codex-rs"), + ); + + (params.items[1].actions[0])(&tx); + + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::CurrentWorktreeSelected { target }) if target == "fcoury/worktrees" + )); + } + #[test] fn worktree_loading_snapshot() { insta::assert_snapshot!(