mirror of
https://github.com/openai/codex.git
synced 2026-09-09 15:58:47 +00:00
Add a managed worktree browser to the TUI (#43286)
## 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
This commit is contained in:
committed by
copyberry
parent
8d7cc24a87
commit
b053ef9e5a
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -419,6 +419,20 @@ pub(crate) enum AppEvent {
|
||||
/// Continue a checkout transition after synchronous Git work finishes off-loop.
|
||||
ManagedWorktreeCreated(Box<ManagedWorktreeCreated>),
|
||||
|
||||
BrowseManagedWorktrees,
|
||||
ManagedWorktreesLoaded {
|
||||
request: crate::worktree_browser::Request,
|
||||
result: Result<Vec<crate::worktree_browser::Entry>, 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,
|
||||
|
||||
@@ -575,6 +575,7 @@ pub(crate) struct ChatWidget {
|
||||
model_catalog: Arc<ModelCatalog>,
|
||||
model_popup_request_id: Option<uuid::Uuid>,
|
||||
permission_popup_request_id: Option<uuid::Uuid>,
|
||||
worktree_popup_request_id: Option<uuid::Uuid>,
|
||||
permission_profiles_menu_opened: bool,
|
||||
model_popup_model_ids: Vec<String>,
|
||||
session_telemetry: SessionTelemetry,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<Request> {
|
||||
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<Vec<Entry>, 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<AppEvent> {
|
||||
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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
64
codex-rs/tui/src/worktree_browser.rs
Normal file
64
codex-rs/tui/src/worktree_browser.rs
Normal file
@@ -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<ThreadId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Entry {
|
||||
pub cwd: PathBuf,
|
||||
pub owner: Option<ThreadId>,
|
||||
}
|
||||
|
||||
#[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<Vec<Entry>> {
|
||||
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?
|
||||
}
|
||||
Reference in New Issue
Block a user