From b053ef9e5a183480d58bd61fed083ee700d88bea Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Sun, 6 Sep 2026 21:21:07 +0000 Subject: [PATCH] Add a managed worktree browser to the TUI (#43286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Add a searchable “Browse worktrees” option to `/worktree` for local repositories with worktrees enabled. List checkouts from the repository's configured pool, show owner metadata, and let users resume an owner thread or copy a worktree's working directory. Load worktrees asynchronously and discard results after the loading popup is dismissed or the request context changes. Ignore actions from stale requests and offer copying when owner metadata is missing or invalid. ## Testing Add popup snapshots and tests for search, resume and copy actions, stale results, and disabled local worktree operations. Extend lifecycle coverage to verify discovery from repository subdirectories and handling of missing or invalid owner metadata. GitOrigin-RevId: 2b7e694a133278da640b6bb275fd5c1202b271c7 --- codex-rs/tui/src/app/event_dispatch.rs | 20 ++ .../app/tests/session_lifecycle_requests.rs | 63 +++++++ codex-rs/tui/src/app_event.rs | 14 ++ codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/constructor.rs | 1 + ...dget__tests__worktree_browser_actions.snap | 12 ++ ...twidget__tests__worktree_browser_list.snap | 12 ++ ...dget__tests__worktree_browser_loading.snap | 10 + ...tests__worktrees_conversation_choices.snap | 7 +- .../chatwidget/tests/worktree_picker_tests.rs | 82 +++++++++ .../tui/src/chatwidget/worktree_picker.rs | 173 +++++++++++++++++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/worktree_browser.rs | 64 +++++++ 13 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap create mode 100644 codex-rs/tui/src/worktree_browser.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 8ceaa3957c..ab21f792fe 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -97,6 +97,26 @@ impl App { AppEvent::ManagedWorktreeCreated(created) => { self.finish_managed_worktree(tui, app_server, *created).await; } + AppEvent::BrowseManagedWorktrees => { + if let Some(request) = self.chat_widget.request_managed_worktrees() { + crate::worktree_browser::fetch( + request, + self.config.codex_home.to_path_buf(), + self.app_event_tx.clone(), + ); + } + } + AppEvent::ManagedWorktreesLoaded { request, result } => { + self.chat_widget.on_managed_worktrees_loaded(request, result); + } + AppEvent::ManagedWorktreeAction { request, action } => { + if let Some(event) = self.chat_widget.managed_worktree_action(&request, action) { + self.app_event_tx.send(event); + } + } + AppEvent::ShowManagedWorktreeActions { request, entry } => { + self.chat_widget.show_managed_worktree_actions(request, entry); + } AppEvent::ChangeWorkingDirectory { thread_id, requested_cwd, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index e9ebf9a357..aff15e577d 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2954,6 +2954,7 @@ model_reasoning_effort = "low" codex_worktree::WorktreeSettings::for_cli(&home, /*desktop*/ None) .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, ); + let mut browser_entries = Vec::new(); requests.lock().expect("request recorder lock").clear(); app.handle_event( &mut tui, @@ -2972,6 +2973,10 @@ model_reasoning_effort = "low" .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))? .pop() .expect("unused checkout"); + browser_entries.push(crate::worktree_browser::Entry { + cwd: unused.cwd.clone(), + owner: None, + }); assert_eq!( manager .owner(&unused.root) @@ -3035,6 +3040,16 @@ terminal_visualization_instructions = true complete_managed_worktree_creation(&mut app, &mut tui, &mut server, &mut events).await?; assert_eq!(app.chat_widget.thread_id(), Some(original)); assert!(recorded_params(&requests, "thread/fork").is_empty()); + let feature_mismatch_checkout = manager + .list(&source) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))? + .into_iter() + .find(|checkout| checkout.cwd != unused.cwd) + .expect("feature mismatch leaves an unused checkout"); + browser_entries.push(crate::worktree_browser::Entry { + cwd: feature_mismatch_checkout.cwd, + owner: None, + }); while events.try_recv().is_ok() {} app.handle_event( &mut tui, @@ -3062,6 +3077,13 @@ terminal_visualization_instructions = true .1 .root .clone(); + let stale_cwd = created + .result + .as_ref() + .expect("created checkout") + .1 + .cwd + .clone(); app.primary_thread_id = Some(ThreadId::new()); app.handle_event( &mut tui, @@ -3089,6 +3111,10 @@ terminal_visualization_instructions = true .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, None ); + browser_entries.push(crate::worktree_browser::Entry { + cwd: stale_cwd, + owner: None, + }); for mode in [ManagedWorktreeMode::New, ManagedWorktreeMode::Fork] { requests.lock().expect("request recorder lock").clear(); let previous = app.chat_widget.thread_id(); @@ -3117,6 +3143,10 @@ terminal_visualization_instructions = true .iter() .find(|checkout| checkout.cwd.canonicalize().ok().as_ref() == Some(&cwd)) .expect("managed checkout"); + browser_entries.push(crate::worktree_browser::Entry { + cwd: checkout.cwd.clone(), + owner: Some(replacement), + }); assert!(checkout.root.starts_with(home.join("worktrees"))); assert!(!project_pool.exists()); assert_eq!( @@ -3173,6 +3203,39 @@ terminal_visualization_instructions = true .await?; assert_eq!(attached.cwd.as_path().canonicalize()?, cwd); } + browser_entries.sort_by(|left, right| left.cwd.cmp(&right.cwd)); + let nested = source.join("browser-only"); + fs::create_dir(&nested)?; + assert_eq!( + crate::worktree_browser::list(home.clone(), nested) + .await + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + browser_entries + ); + let unowned = manager + .create(&codex_worktree::CreateWorktree { + source_cwd: source.clone(), + base: None, + }) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + browser_entries.push(crate::worktree_browser::Entry { + cwd: unowned.cwd, + owner: None, + }); + browser_entries.sort_by(|left, right| left.cwd.cmp(&right.cwd)); + for owner in [None, Some("not-a-thread-uuid")] { + if let Some(owner) = owner { + manager + .bind_thread(&unowned.root, owner) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + assert_eq!( + crate::worktree_browser::list(home.clone(), source.clone()) + .await + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + browser_entries + ); + } app.start_fresh_session_with_summary_hint( &mut tui, &mut server, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 819b8ace8f..f08692a148 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -419,6 +419,20 @@ pub(crate) enum AppEvent { /// Continue a checkout transition after synchronous Git work finishes off-loop. ManagedWorktreeCreated(Box), + BrowseManagedWorktrees, + ManagedWorktreesLoaded { + request: crate::worktree_browser::Request, + result: Result, String>, + }, + ManagedWorktreeAction { + request: crate::worktree_browser::Request, + action: crate::worktree_browser::Action, + }, + ShowManagedWorktreeActions { + request: crate::worktree_browser::Request, + entry: crate::worktree_browser::Entry, + }, + /// Change the working directory of the originating idle primary thread. ChangeWorkingDirectory { thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 852eaa1ff6..13aaa4adf8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -575,6 +575,7 @@ pub(crate) struct ChatWidget { model_catalog: Arc, model_popup_request_id: Option, permission_popup_request_id: Option, + worktree_popup_request_id: Option, permission_profiles_menu_opened: bool, model_popup_model_ids: Vec, session_telemetry: SessionTelemetry, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index e2ad6d54c6..6a2d5dee83 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -127,6 +127,7 @@ impl ChatWidget { model_catalog, model_popup_request_id: None, permission_popup_request_id: None, + worktree_popup_request_id: None, permission_profiles_menu_opened: false, model_popup_model_ids: Vec::new(), session_telemetry, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap new file mode 100644 index 0000000000..59abf1a3ed --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 114 +expression: "render_bottom_popup(&chat, 80)" +--- + Worktree + /repo/worktree + +› 1. Resume owner thread + 2. Copy working directory + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap new file mode 100644 index 0000000000..61d7777386 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 100 +expression: "render_bottom_popup(&chat, 80)" +--- + Managed worktrees + Select a worktree to resume its owner or copy its working directory + + +› /repo/worktree Owner: 00000000-0000-0000-0000-000000000001 + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap new file mode 100644 index 0000000000..e6381ff916 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 90 +expression: "render_bottom_popup(&chat, 80)" +--- + Managed worktrees + +› Loading worktrees… + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap index a91fc16b17..ddf97f9c3f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap @@ -1,12 +1,15 @@ --- -source: tui/src/chatwidget/tests/slash_commands.rs +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 71 expression: popup --- - Create a new worktree + Worktrees › 1. Continue current conversation Preserve this conversation in the new checkout 2. Start new conversation Open a fresh conversation in the new checkout + 3. Browse worktrees Resume an owner thread or copy a working + directory Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs index d6fc917381..ebfa30af68 100644 --- a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs @@ -89,3 +89,85 @@ async fn slash_worktree_offers_current_or_new_conversation() { assert!(popup.contains("Start new conversation"), "popup: {popup}"); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } + +#[tokio::test] +async fn worktree_browser_actions_and_stale_results() { + use crate::worktree_browser::Entry; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let checkout = tempdir().unwrap(); + std::fs::create_dir(checkout.path().join(".git")).unwrap(); + std::fs::write(checkout.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + chat.config.cwd = AbsolutePathBuf::from_absolute_path(checkout.path()).unwrap(); + chat.set_feature_enabled(Feature::Worktrees, /*enabled*/ true); + let request = chat.request_managed_worktrees().unwrap(); + assert_chatwidget_snapshot!( + "worktree_browser_loading", + render_bottom_popup(&chat, /*width*/ 80) + ); + let owner = ThreadId::from_string("00000000-0000-0000-0000-000000000001").unwrap(); + let entry = Entry { + cwd: PathBuf::from("/repo/worktree"), + owner: Some(owner), + }; + chat.on_managed_worktrees_loaded(request, Ok(vec![entry.clone()])); + assert_chatwidget_snapshot!( + "worktree_browser_list", + render_bottom_popup(&chat, /*width*/ 80) + ); + for character in "worktree".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(character))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let AppEvent::ShowManagedWorktreeActions { + request, + entry: selected, + } = rx.try_recv().unwrap() + else { + panic!("worktree action"); + }; + assert_eq!(selected, entry); + chat.show_managed_worktree_actions(request.clone(), selected); + assert_chatwidget_snapshot!( + "worktree_browser_actions", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let AppEvent::ManagedWorktreeAction { + request: selected_request, + action, + } = rx.try_recv().unwrap() + else { + panic!("resume action"); + }; + assert_matches!(chat.managed_worktree_action(&selected_request, action.clone()), Some(AppEvent::ResumeSessionByIdOrName(id)) if id == owner.to_string()); + chat.set_local_worktree_operations(/*enabled*/ false); + assert!( + chat.managed_worktree_action(&selected_request, action) + .is_none() + ); + chat.set_local_worktree_operations(/*enabled*/ true); + chat.show_managed_worktree_actions( + request, + Entry { + owner: None, + ..entry + }, + ); + assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Resume owner")); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let AppEvent::ManagedWorktreeAction { request, action } = rx.try_recv().unwrap() else { + panic!("copy action"); + }; + assert_matches!(chat.managed_worktree_action(&request, action), Some(AppEvent::CopySelection { text, .. }) if &*text == "/repo/worktree"); + let first = chat.request_managed_worktrees().unwrap(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.on_managed_worktrees_loaded(first, Ok(Vec::new())); + assert!(!chat.bottom_pane.has_active_view()); + let stale = chat.request_managed_worktrees().unwrap(); + let current = chat.request_managed_worktrees().unwrap(); + chat.on_managed_worktrees_loaded(stale, Err("stale result".to_string())); + assert_eq!(chat.worktree_popup_request_id, Some(current.id)); + chat.config.cwd = AbsolutePathBuf::from_absolute_path(checkout.path().join("other")).unwrap(); + chat.on_managed_worktrees_loaded(current, Ok(Vec::new())); + assert!(!chat.bottom_pane.has_active_view()); +} diff --git a/codex-rs/tui/src/chatwidget/worktree_picker.rs b/codex-rs/tui/src/chatwidget/worktree_picker.rs index 31359aa8eb..d9a8d3b067 100644 --- a/codex-rs/tui/src/chatwidget/worktree_picker.rs +++ b/codex-rs/tui/src/chatwidget/worktree_picker.rs @@ -2,6 +2,11 @@ use super::*; use crate::app_event::ManagedWorktreeMode; +use crate::worktree_browser::Action; +use crate::worktree_browser::Entry; +use crate::worktree_browser::Request; + +const BROWSER_VIEW_ID: &str = "managed-worktrees"; impl ChatWidget { pub(super) fn managed_worktree_available(&self) -> bool { @@ -86,7 +91,7 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Create a new worktree".to_string()), + title: Some("Worktrees".to_string()), footer_hint: Some(standard_popup_hint_line()), items: vec![ SelectionItem { @@ -113,9 +118,175 @@ impl ChatWidget { dismiss_on_select: true, ..Default::default() }, + SelectionItem { + name: "Browse worktrees".to_string(), + description: Some( + "Resume an owner thread or copy a working directory".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::BrowseManagedWorktrees))], + dismiss_on_select: true, + ..Default::default() + }, ], ..Default::default() }); self.request_redraw(); } + + pub(crate) fn request_managed_worktrees(&mut self) -> Option { + if !self.managed_worktree_available() { + return None; + } + let request = Request { + id: uuid::Uuid::new_v4(), + cwd: self.config.cwd.to_path_buf(), + thread_id: self.thread_id, + }; + self.bottom_pane.dismiss_view_by_id(BROWSER_VIEW_ID); + self.worktree_popup_request_id = Some(request.id); + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(BROWSER_VIEW_ID), + title: Some("Managed worktrees".to_string()), + items: vec![SelectionItem { + name: "Loading worktrees…".to_string(), + is_disabled: true, + ..Default::default() + }], + footer_hint: Some(standard_popup_hint_line()), + ..Default::default() + }); + Some(request) + } + + fn worktree_request_is_current(&self, request: &Request) -> bool { + self.worktree_popup_request_id == Some(request.id) + && request.cwd == self.config.cwd.as_path() + && request.thread_id == self.thread_id + && self.managed_worktree_available() + } + + pub(crate) fn on_managed_worktrees_loaded( + &mut self, + request: Request, + result: Result, String>, + ) { + if self.worktree_popup_request_id != Some(request.id) { + return; + } + if !self.worktree_request_is_current(&request) + || !self.bottom_pane.dismiss_active_view_if_id(BROWSER_VIEW_ID) + { + self.worktree_popup_request_id = None; + self.bottom_pane.dismiss_view_by_id(BROWSER_VIEW_ID); + return; + } + let entries = match result { + Ok(entries) => entries, + Err(error) => { + self.worktree_popup_request_id = None; + self.add_error_message(format!("Cannot list managed worktrees: {error}")); + return; + } + }; + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Managed worktrees".to_string()), + subtitle: Some( + if entries.is_empty() { + "No worktrees in this repository's configured pool" + } else { + "Select a worktree to resume its owner or copy its working directory" + } + .to_string(), + ), + is_searchable: true, + items: entries + .into_iter() + .map(|entry| { + let request = request.clone(); + SelectionItem { + name: entry.cwd.display().to_string(), + search_value: Some(entry.cwd.display().to_string()), + description: Some(entry.owner.map_or_else( + || "No owner metadata".to_string(), + |owner| format!("Owner: {owner}"), + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ShowManagedWorktreeActions { + request: request.clone(), + entry: entry.clone(), + }) + })], + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(), + footer_hint: Some(standard_popup_hint_line()), + ..Default::default() + }); + } + + pub(crate) fn managed_worktree_action( + &self, + request: &Request, + action: Action, + ) -> Option { + if !self.worktree_request_is_current(request) { + return None; + } + Some(match action { + Action::Resume(owner) => AppEvent::ResumeSessionByIdOrName(owner.to_string()), + Action::Copy(cwd) => AppEvent::CopySelection { + text: cwd.to_str()?.into(), + label: "Worktree working directory".to_string(), + format: crate::clipboard_copy::CopyFormat::PlainText, + }, + }) + } + + pub(crate) fn show_managed_worktree_actions(&mut self, request: Request, entry: Entry) { + if !self.worktree_request_is_current(&request) { + return; + } + let mut items = Vec::new(); + if let Some(owner) = entry.owner { + let request = request.clone(); + items.push(SelectionItem { + name: "Resume owner thread".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ManagedWorktreeAction { + request: request.clone(), + action: Action::Resume(owner), + }) + })], + dismiss_on_select: true, + ..Default::default() + }); + } + let cwd = entry.cwd.clone(); + items.push(SelectionItem { + name: "Copy working directory".to_string(), + is_disabled: entry.cwd.to_str().is_none(), + disabled_reason: entry + .cwd + .to_str() + .is_none() + .then(|| "Path is not valid UTF-8".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ManagedWorktreeAction { + request: request.clone(), + action: Action::Copy(cwd.clone()), + }) + })], + dismiss_on_select: true, + ..Default::default() + }); + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Worktree".to_string()), + subtitle: Some(entry.cwd.display().to_string()), + items, + footer_hint: Some(standard_popup_hint_line()), + ..Default::default() + }); + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 89a3746a13..5514c66907 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -127,6 +127,7 @@ mod daybreak; mod experimental_features; mod permission_discovery; mod pets; +mod worktree_browser; pub use custom_terminal::Terminal; mod assistant_directives; mod auto_review_denials; diff --git a/codex-rs/tui/src/worktree_browser.rs b/codex-rs/tui/src/worktree_browser.rs new file mode 100644 index 0000000000..723870af4d --- /dev/null +++ b/codex-rs/tui/src/worktree_browser.rs @@ -0,0 +1,64 @@ +//! Read-only discovery for the local worktree browser; ownership is not activity or exclusion. + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use codex_protocol::ThreadId; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub(crate) struct Request { + pub id: uuid::Uuid, + pub cwd: PathBuf, + pub thread_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Entry { + pub cwd: PathBuf, + pub owner: Option, +} + +#[derive(Clone, Debug)] +pub(crate) enum Action { + Resume(ThreadId), + Copy(PathBuf), +} + +pub(crate) fn fetch(request: Request, codex_home: PathBuf, tx: AppEventSender) { + tokio::spawn(async move { + let result = list(codex_home, request.cwd.clone()) + .await + .map_err(|error| error.to_string()); + tx.send(AppEvent::ManagedWorktreesLoaded { request, result }); + }); +} + +pub(crate) async fn list(codex_home: PathBuf, cwd: PathBuf) -> anyhow::Result> { + let host = crate::legacy_core::config::load_config_toml_with_layer_stack( + &codex_home, + /*cwd*/ None, + Vec::new(), + codex_config::ConfigLoadOptions::default(), + ) + .await?; + let settings = + codex_worktree::WorktreeSettings::for_cli(&codex_home, host.config_toml.desktop.as_ref())?; + // Closing the popup discards its result; an already-running blocking Git call still finishes. + tokio::task::spawn_blocking(move || { + let cwd = codex_git_utils::get_git_repo_root(&cwd).unwrap_or(cwd); + let manager = codex_worktree::WorktreeManager::new(settings); + Ok(manager + .list(&cwd)? + .into_iter() + .map(|checkout| Entry { + owner: manager + .owner(&checkout.root) + .ok() + .flatten() + .and_then(|owner| ThreadId::from_string(&owner).ok()), + cwd: checkout.cwd, + }) + .collect()) + }) + .await? +}