mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
feat(tui): add worktree slash command
This commit is contained in:
@@ -201,6 +201,7 @@ mod thread_events;
|
||||
mod thread_goal_actions;
|
||||
mod thread_routing;
|
||||
mod thread_session_state;
|
||||
mod worktree;
|
||||
|
||||
use self::agent_navigation::AgentNavigationDirection;
|
||||
use self::agent_navigation::AgentNavigationState;
|
||||
|
||||
@@ -187,6 +187,41 @@ impl App {
|
||||
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenWorktreePicker => {
|
||||
self.open_worktree_picker(tui);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::WorktreesLoaded { cwd, result } => {
|
||||
self.on_worktrees_loaded(cwd, result);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::CreateWorktreeAndSwitch {
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
} => {
|
||||
self.create_worktree_and_switch(tui, app_server, branch, base_ref, dirty_policy)
|
||||
.await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::SwitchToWorktree { target } => {
|
||||
self.switch_to_worktree_target(tui, app_server, target)
|
||||
.await;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::ShowWorktreePath { target } => {
|
||||
self.show_worktree_path(target);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::RemoveWorktree {
|
||||
target,
|
||||
force,
|
||||
delete_branch,
|
||||
confirmed,
|
||||
} => {
|
||||
self.remove_worktree(target, force, delete_branch, confirmed);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::BeginInitialHistoryReplayBuffer => {
|
||||
self.begin_initial_history_replay_buffer();
|
||||
}
|
||||
|
||||
352
codex-rs/tui/src/app/worktree.rs
Normal file
352
codex-rs/tui/src/app/worktree.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
//! App-layer handlers for the worktree TUI flow.
|
||||
|
||||
use codex_worktree::DirtyPolicy;
|
||||
use codex_worktree::WorktreeInfo;
|
||||
use codex_worktree::WorktreeListQuery;
|
||||
use codex_worktree::WorktreeRemoveRequest;
|
||||
use codex_worktree::WorktreeRequest;
|
||||
use codex_worktree::WorktreeSource;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.chat_widget
|
||||
.show_selection_view(crate::worktree::loading_params(
|
||||
tui.frame_requester(),
|
||||
self.config.animations,
|
||||
));
|
||||
self.fetch_worktrees_for_picker();
|
||||
}
|
||||
|
||||
pub(super) fn on_worktrees_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
result: Result<Vec<WorktreeInfo>, String>,
|
||||
) {
|
||||
if cwd.as_path() != self.config.cwd.as_path() {
|
||||
return;
|
||||
}
|
||||
let params = match result {
|
||||
Ok(entries) if entries.is_empty() => crate::worktree::empty_params(),
|
||||
Ok(entries) => crate::worktree::picker_params(entries, self.config.cwd.as_path()),
|
||||
Err(err) => crate::worktree::error_params(err),
|
||||
};
|
||||
self.replace_worktree_view(params);
|
||||
}
|
||||
|
||||
pub(super) async fn create_worktree_and_switch(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
dirty_policy: Option<DirtyPolicy>,
|
||||
) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dirty_policy = match dirty_policy {
|
||||
Some(policy) => policy,
|
||||
None => match codex_worktree::dirty_state(self.config.cwd.as_path()) {
|
||||
Ok(state) if state.is_dirty() => {
|
||||
let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref);
|
||||
self.chat_widget.show_selection_view(params);
|
||||
return;
|
||||
}
|
||||
Ok(_) => DirtyPolicy::Fail,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to inspect source checkout: {err}"));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let resolution = match codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: self.config.cwd.to_path_buf(),
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
}) {
|
||||
Ok(resolution) => resolution,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to create worktree: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let warnings = resolution
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|warning| warning.message.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let target = resolution
|
||||
.info
|
||||
.branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.info.name.clone());
|
||||
self.show_worktree_switching_view(tui, target).await;
|
||||
self.switch_to_worktree_info(tui, app_server, resolution.info)
|
||||
.await;
|
||||
for warning in warnings {
|
||||
self.chat_widget.add_info_message(warning, /*hint*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn switch_to_worktree_target(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
target: String,
|
||||
) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.show_worktree_switching_view(tui, target.clone()).await;
|
||||
let entries = match self.list_current_repo_worktrees() {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
self.show_worktree_error("Failed to list worktrees.".to_string(), err.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let info = match crate::worktree::find_worktree(&entries, &target) {
|
||||
Ok(info) => info.clone(),
|
||||
Err(err) => {
|
||||
self.show_worktree_error("Failed to switch worktree.".to_string(), err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.switch_to_worktree_info(tui, app_server, info).await;
|
||||
}
|
||||
|
||||
pub(super) fn show_worktree_path(&mut self, target: String) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
match self.list_current_repo_worktrees() {
|
||||
Ok(entries) => match crate::worktree::find_worktree(&entries, &target) {
|
||||
Ok(info) => {
|
||||
self.chat_widget.add_info_message(
|
||||
info.workspace_cwd.display().to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
Err(err) => self.chat_widget.add_error_message(err),
|
||||
},
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to list worktrees: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_worktree(
|
||||
&mut self,
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
confirmed: bool,
|
||||
) {
|
||||
if self.remote_app_server_url.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
"/worktree is not supported for remote sessions yet.".to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let entries = match self.list_current_repo_worktrees() {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to list worktrees: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let info = match crate::worktree::find_worktree(&entries, &target) {
|
||||
Ok(info) => info,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if info.source != WorktreeSource::Cli {
|
||||
let source = crate::worktree::source_label(info.source);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Refusing to remove {source} worktree '{target}'. Only Codex CLI-managed worktrees can be removed."
|
||||
));
|
||||
return;
|
||||
}
|
||||
if !confirmed {
|
||||
let params = crate::worktree::remove_confirmation_params(target, force, delete_branch);
|
||||
self.chat_widget.show_selection_view(params);
|
||||
return;
|
||||
}
|
||||
|
||||
match codex_worktree::remove_worktree(WorktreeRemoveRequest {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: Some(self.config.cwd.to_path_buf()),
|
||||
name_or_path: target.clone(),
|
||||
force,
|
||||
delete_branch,
|
||||
}) {
|
||||
Ok(result) => {
|
||||
let mut message = format!("Removed worktree {}", result.removed_path.display());
|
||||
if let Some(branch) = result.deleted_branch {
|
||||
message.push_str(&format!(" and deleted branch {branch}"));
|
||||
}
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to remove worktree: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_current_repo_worktrees(&self) -> anyhow::Result<Vec<WorktreeInfo>> {
|
||||
codex_worktree::list_worktrees(WorktreeListQuery {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: Some(self.config.cwd.to_path_buf()),
|
||||
include_all_repos: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_worktrees_for_picker(&mut self) {
|
||||
let query = WorktreeListQuery {
|
||||
codex_home: self.config.codex_home.to_path_buf(),
|
||||
source_cwd: Some(self.config.cwd.to_path_buf()),
|
||||
include_all_repos: false,
|
||||
};
|
||||
let cwd = self.config.cwd.to_path_buf();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_worktree::list_worktrees(query).map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::WorktreesLoaded { cwd, result });
|
||||
});
|
||||
}
|
||||
|
||||
async fn switch_to_worktree_info(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
info: WorktreeInfo,
|
||||
) {
|
||||
let mut config = match self
|
||||
.rebuild_config_for_cwd(info.workspace_cwd.clone())
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.show_worktree_error(
|
||||
"Failed to rebuild configuration for worktree.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.apply_runtime_policy_overrides(&mut config);
|
||||
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
match app_server.fork_thread(config.clone(), thread_id).await {
|
||||
Ok(forked) => {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
self.install_worktree_config(tui, config);
|
||||
if let Err(err) = self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui, app_server, forked, /*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.show_worktree_error(
|
||||
"Failed to attach to worktree thread.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.show_worktree_error(
|
||||
"Failed to fork current session into worktree.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
self.install_worktree_config(tui, config.clone());
|
||||
match app_server.start_thread(&config).await {
|
||||
Ok(started) => {
|
||||
if let Err(err) = self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui, app_server, started, /*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.show_worktree_error(
|
||||
"Failed to attach to worktree thread.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.show_worktree_error(
|
||||
"Failed to start session in worktree.".to_string(),
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
async fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
|
||||
self.chat_widget
|
||||
.show_selection_view(crate::worktree::switching_params(
|
||||
target,
|
||||
tui.frame_requester(),
|
||||
self.config.animations,
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool {
|
||||
self.chat_widget
|
||||
.replace_selection_view_if_active(crate::worktree::WORKTREE_SELECTION_VIEW_ID, params)
|
||||
}
|
||||
|
||||
fn show_worktree_error(&mut self, summary: String, error: String) {
|
||||
let params = crate::worktree::error_with_summary_params(summary.clone(), error.clone());
|
||||
if !self.replace_worktree_view(params) {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("{summary} {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn install_worktree_config(&mut self, tui: &mut tui::Tui, config: Config) {
|
||||
self.config = config;
|
||||
tui.set_notification_settings(
|
||||
self.config.tui_notifications.method,
|
||||
self.config.tui_notifications.condition,
|
||||
);
|
||||
self.file_search
|
||||
.update_search_dir(self.config.cwd.to_path_buf());
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
use codex_worktree::DirtyPolicy;
|
||||
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
@@ -191,6 +192,40 @@ pub(crate) enum AppEvent {
|
||||
/// Fork the current session into a new thread.
|
||||
ForkCurrentSession,
|
||||
|
||||
/// Open the managed worktree picker.
|
||||
OpenWorktreePicker,
|
||||
|
||||
/// Result of loading worktrees for the managed worktree picker.
|
||||
WorktreesLoaded {
|
||||
cwd: PathBuf,
|
||||
result: Result<Vec<codex_worktree::WorktreeInfo>, String>,
|
||||
},
|
||||
|
||||
/// Create or reuse a managed worktree and switch the TUI into it.
|
||||
CreateWorktreeAndSwitch {
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
dirty_policy: Option<DirtyPolicy>,
|
||||
},
|
||||
|
||||
/// Switch the TUI into an existing worktree.
|
||||
SwitchToWorktree {
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Show the filesystem path for an existing worktree.
|
||||
ShowWorktreePath {
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Remove a Codex-managed worktree.
|
||||
RemoveWorktree {
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
confirmed: bool,
|
||||
},
|
||||
|
||||
/// Request to exit the application.
|
||||
///
|
||||
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
|
||||
|
||||
@@ -5377,6 +5377,21 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn replace_selection_view_if_active(
|
||||
&mut self,
|
||||
view_id: &'static str,
|
||||
params: SelectionViewParams,
|
||||
) -> bool {
|
||||
let replaced = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(view_id, params);
|
||||
if replaced {
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.request_redraw();
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
|
||||
self.bottom_pane.no_modal_or_popup_active()
|
||||
}
|
||||
|
||||
@@ -154,6 +154,9 @@ impl ChatWidget {
|
||||
SlashCommand::Fork => {
|
||||
self.app_event_tx.send(AppEvent::ForkCurrentSession);
|
||||
}
|
||||
SlashCommand::Worktree => {
|
||||
self.app_event_tx.send(AppEvent::OpenWorktreePicker);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
if init_target.exists() {
|
||||
@@ -772,6 +775,13 @@ impl ChatWidget {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ResumeSessionByIdOrName(args));
|
||||
}
|
||||
SlashCommand::Worktree if !trimmed.is_empty() => {
|
||||
if let Err(message) =
|
||||
crate::worktree::dispatch_worktree_slash_args(trimmed, &self.app_event_tx)
|
||||
{
|
||||
self.add_error_message(message);
|
||||
}
|
||||
}
|
||||
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
|
||||
@@ -918,6 +928,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Review
|
||||
|
||||
@@ -179,6 +179,7 @@ mod tui;
|
||||
mod ui_consts;
|
||||
pub(crate) mod update_action;
|
||||
pub use update_action::UpdateAction;
|
||||
mod worktree;
|
||||
mod worktree_labels;
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use update_action::get_update_action;
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum SlashCommand {
|
||||
New,
|
||||
Resume,
|
||||
Fork,
|
||||
Worktree,
|
||||
Init,
|
||||
Compact,
|
||||
Plan,
|
||||
@@ -87,6 +88,7 @@ impl SlashCommand {
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Clear => "clear the terminal and start a new chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
SlashCommand::Worktree => "manage worktrees",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Copy => "copy last response as markdown",
|
||||
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
|
||||
@@ -158,6 +160,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::SandboxReadRoot
|
||||
)
|
||||
}
|
||||
@@ -181,6 +184,7 @@ impl SlashCommand {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Worktree
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_string(), None),\n82)"
|
||||
---
|
||||
|
||||
Source checkout has uncommitted changes
|
||||
Choose what to carry into the new worktree.
|
||||
|
||||
› 1. Fail Cancel creation and leave the source checkout unchanged.
|
||||
2. Ignore Create from the requested base without copying local changes.
|
||||
3. Copy tracked Copy staged and unstaged tracked changes.
|
||||
4. Copy all Copy tracked changes and untracked files.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(loading_params(FrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Loading worktrees...
|
||||
This can take a moment when Codex is checking app, CLI, and Git worktrees.
|
||||
|
||||
› Loading worktrees... This can take a moment when Codex is checking app, CLI, and Git
|
||||
worktrees.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(params, 86)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
Select a worktree to fork this chat into that workspace.
|
||||
|
||||
Search worktrees
|
||||
› fcoury/demo (current) Fork this chat into /repo/codex.fcoury-demo
|
||||
codex clean · app · /repo/codex.codex
|
||||
main dirty · git · /repo/codex.main
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(remove_confirmation_params(\"fcoury/demo\".to_string(), false,\nfalse), 80)"
|
||||
---
|
||||
|
||||
Remove worktree fcoury/demo?
|
||||
Only Codex-managed worktrees can be removed.
|
||||
|
||||
› 1. Remove Remove the selected worktree.
|
||||
2. Cancel Keep the worktree.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/worktree.rs
|
||||
expression: "render_selection(switching_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
|
||||
---
|
||||
|
||||
Worktrees
|
||||
• Switching to fcoury/demo...
|
||||
Codex is rebuilding configuration and starting the chat in that workspace.
|
||||
|
||||
› Preparing worktree session... Codex is rebuilding configuration and starting the
|
||||
chat in that workspace.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
708
codex-rs/tui/src/worktree.rs
Normal file
708
codex-rs/tui/src/worktree.rs
Normal file
@@ -0,0 +1,708 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_worktree::DirtyPolicy;
|
||||
use codex_worktree::WorktreeInfo;
|
||||
use codex_worktree::WorktreeSource;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionRowDisplay;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
use crate::motion::activity_indicator;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
const WORKTREE_USAGE: &str =
|
||||
"Usage: /worktree [list|new <branch>|switch <branch>|path <branch>|remove <branch>]";
|
||||
pub(crate) const WORKTREE_SELECTION_VIEW_ID: &str = "worktree-selection";
|
||||
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
struct WorktreeLoadingHeader {
|
||||
started_at: Instant,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
status: String,
|
||||
note: String,
|
||||
}
|
||||
|
||||
impl WorktreeLoadingHeader {
|
||||
fn new(
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
status: String,
|
||||
note: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
status,
|
||||
note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for WorktreeLoadingHeader {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled);
|
||||
if self.animations_enabled {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
|
||||
}
|
||||
|
||||
let mut loading_spans = Vec::new();
|
||||
if let Some(indicator) = activity_indicator(
|
||||
Some(self.started_at),
|
||||
motion_mode,
|
||||
ReducedMotionIndicator::StaticBullet,
|
||||
) {
|
||||
loading_spans.push(indicator);
|
||||
loading_spans.push(" ".into());
|
||||
}
|
||||
loading_spans.push(self.status.clone().dim());
|
||||
|
||||
Paragraph::new(vec![
|
||||
Line::from("Worktrees".bold()),
|
||||
Line::from(loading_spans),
|
||||
Line::from(self.note.clone().dim()),
|
||||
])
|
||||
.render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum WorktreeSlashAction {
|
||||
OpenPicker,
|
||||
Create {
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
dirty_policy: Option<DirtyPolicy>,
|
||||
},
|
||||
Switch {
|
||||
target: String,
|
||||
},
|
||||
ShowPath {
|
||||
target: String,
|
||||
},
|
||||
Remove {
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorktreeSlashAction {
|
||||
pub(crate) fn dispatch(self, tx: &AppEventSender) {
|
||||
match self {
|
||||
WorktreeSlashAction::OpenPicker => tx.send(AppEvent::OpenWorktreePicker),
|
||||
WorktreeSlashAction::Create {
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
} => tx.send(AppEvent::CreateWorktreeAndSwitch {
|
||||
branch,
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
}),
|
||||
WorktreeSlashAction::Switch { target } => {
|
||||
tx.send(AppEvent::SwitchToWorktree { target });
|
||||
}
|
||||
WorktreeSlashAction::ShowPath { target } => {
|
||||
tx.send(AppEvent::ShowWorktreePath { target });
|
||||
}
|
||||
WorktreeSlashAction::Remove {
|
||||
target,
|
||||
force,
|
||||
delete_branch,
|
||||
} => tx.send(AppEvent::RemoveWorktree {
|
||||
target,
|
||||
force,
|
||||
delete_branch,
|
||||
confirmed: force,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_worktree_slash_args(args: &str) -> Result<WorktreeSlashAction, String> {
|
||||
let mut parts = args.split_whitespace();
|
||||
let Some(command) = parts.next() else {
|
||||
return Ok(WorktreeSlashAction::OpenPicker);
|
||||
};
|
||||
match command {
|
||||
"list" => Ok(WorktreeSlashAction::OpenPicker),
|
||||
"new" => parse_new(parts),
|
||||
"switch" | "move" => {
|
||||
let target = required_target(parts, command)?;
|
||||
Ok(WorktreeSlashAction::Switch { target })
|
||||
}
|
||||
"path" => {
|
||||
let target = required_target(parts, command)?;
|
||||
Ok(WorktreeSlashAction::ShowPath { target })
|
||||
}
|
||||
"remove" => parse_remove(parts),
|
||||
_ => Err(WORKTREE_USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_new<'a>(mut parts: impl Iterator<Item = &'a str>) -> Result<WorktreeSlashAction, String> {
|
||||
let Some(branch) = parts.next() else {
|
||||
return Err("Usage: /worktree new <branch> [--base <ref>] [--dirty <mode>]".to_string());
|
||||
};
|
||||
let mut base_ref = None;
|
||||
let mut dirty_policy = None;
|
||||
while let Some(flag) = parts.next() {
|
||||
match flag {
|
||||
"--base" => {
|
||||
let Some(value) = parts.next() else {
|
||||
return Err("Usage: /worktree new <branch> --base <ref>".to_string());
|
||||
};
|
||||
base_ref = Some(value.to_string());
|
||||
}
|
||||
"--dirty" => {
|
||||
let Some(value) = parts.next() else {
|
||||
return Err("Usage: /worktree new <branch> --dirty <mode>".to_string());
|
||||
};
|
||||
dirty_policy = Some(parse_dirty_policy(value)?);
|
||||
}
|
||||
_ => return Err(format!("Unknown /worktree new option '{flag}'.")),
|
||||
}
|
||||
}
|
||||
Ok(WorktreeSlashAction::Create {
|
||||
branch: branch.to_string(),
|
||||
base_ref,
|
||||
dirty_policy,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_remove<'a>(
|
||||
mut parts: impl Iterator<Item = &'a str>,
|
||||
) -> Result<WorktreeSlashAction, String> {
|
||||
let Some(target) = parts.next() else {
|
||||
return Err(
|
||||
"Usage: /worktree remove <branch-or-name> [--force] [--delete-branch]".to_string(),
|
||||
);
|
||||
};
|
||||
let mut force = false;
|
||||
let mut delete_branch = false;
|
||||
for flag in parts {
|
||||
match flag {
|
||||
"--force" => force = true,
|
||||
"--delete-branch" => delete_branch = true,
|
||||
_ => return Err(format!("Unknown /worktree remove option '{flag}'.")),
|
||||
}
|
||||
}
|
||||
Ok(WorktreeSlashAction::Remove {
|
||||
target: target.to_string(),
|
||||
force,
|
||||
delete_branch,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_target<'a>(
|
||||
mut parts: impl Iterator<Item = &'a str>,
|
||||
command: &str,
|
||||
) -> Result<String, String> {
|
||||
let Some(target) = parts.next() else {
|
||||
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
|
||||
}
|
||||
Ok(target.to_string())
|
||||
}
|
||||
|
||||
fn parse_dirty_policy(value: &str) -> Result<DirtyPolicy, String> {
|
||||
match value {
|
||||
"fail" => Ok(DirtyPolicy::Fail),
|
||||
"ignore" => Ok(DirtyPolicy::Ignore),
|
||||
"copy-tracked" => Ok(DirtyPolicy::CopyTracked),
|
||||
"copy-all" => Ok(DirtyPolicy::CopyAll),
|
||||
_ => Err("Dirty mode must be one of: fail, ignore, copy-tracked, copy-all.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_worktree_slash_args(args: &str, tx: &AppEventSender) -> Result<(), String> {
|
||||
parse_worktree_slash_args(args)?.dispatch(tx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn loading_params(
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
) -> SelectionViewParams {
|
||||
let status = "Loading worktrees...".to_string();
|
||||
let note =
|
||||
"This can take a moment when Codex is checking app, CLI, and Git worktrees.".to_string();
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(WorktreeLoadingHeader::new(
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
status.clone(),
|
||||
note.clone(),
|
||||
)),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: status,
|
||||
description: Some(note),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn switching_params(
|
||||
target: String,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
) -> SelectionViewParams {
|
||||
let status = format!("Switching to {target}...");
|
||||
let note =
|
||||
"Codex is rebuilding configuration and starting the chat in that workspace.".to_string();
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(WorktreeLoadingHeader::new(
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
status,
|
||||
note.clone(),
|
||||
)),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Preparing worktree session...".to_string(),
|
||||
description: Some(note),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn empty_params() -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Worktrees".bold()));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: "No worktrees found for this repository.".to_string(),
|
||||
description: Some("Use /worktree new <branch> to create one.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn error_params(error: String) -> SelectionViewParams {
|
||||
error_with_summary_params("Failed to list worktrees.".to_string(), error)
|
||||
}
|
||||
|
||||
pub(crate) fn error_with_summary_params(summary: String, error: String) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Worktrees".bold()));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: summary,
|
||||
description: Some(error),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn picker_params(entries: Vec<WorktreeInfo>, current_cwd: &Path) -> SelectionViewParams {
|
||||
let items = entries
|
||||
.into_iter()
|
||||
.map(|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() {
|
||||
"dirty"
|
||||
} else {
|
||||
"clean"
|
||||
};
|
||||
let description = format!("{status} · {source} · {}", entry.workspace_cwd.display());
|
||||
let search_value = Some(format!(
|
||||
"{} {} {} {}",
|
||||
target,
|
||||
entry.name,
|
||||
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(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
search_value,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Worktrees".bold()));
|
||||
header.push(Line::from(
|
||||
"Select a worktree to fork this chat into that workspace.".dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Search worktrees".to_string()),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
row_display: SelectionRowDisplay::SingleLine,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dirty_policy_prompt_params(
|
||||
branch: String,
|
||||
base_ref: Option<String>,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Source checkout has uncommitted changes".bold()));
|
||||
header.push(Line::from(
|
||||
"Choose what to carry into the new worktree.".dim(),
|
||||
));
|
||||
let item = |name: &str, description: &str, dirty_policy: DirtyPolicy| SelectionItem {
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
actions: vec![Box::new({
|
||||
let branch = branch.clone();
|
||||
let base_ref = base_ref.clone();
|
||||
move |tx| {
|
||||
tx.send(AppEvent::CreateWorktreeAndSwitch {
|
||||
branch: branch.clone(),
|
||||
base_ref: base_ref.clone(),
|
||||
dirty_policy: Some(dirty_policy),
|
||||
});
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
};
|
||||
SelectionViewParams {
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
item(
|
||||
"Fail",
|
||||
"Cancel creation and leave the source checkout unchanged.",
|
||||
DirtyPolicy::Fail,
|
||||
),
|
||||
item(
|
||||
"Ignore",
|
||||
"Create from the requested base without copying local changes.",
|
||||
DirtyPolicy::Ignore,
|
||||
),
|
||||
item(
|
||||
"Copy tracked",
|
||||
"Copy staged and unstaged tracked changes.",
|
||||
DirtyPolicy::CopyTracked,
|
||||
),
|
||||
item(
|
||||
"Copy all",
|
||||
"Copy tracked changes and untracked files.",
|
||||
DirtyPolicy::CopyAll,
|
||||
),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_confirmation_params(
|
||||
target: String,
|
||||
force: bool,
|
||||
delete_branch: bool,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from(format!("Remove worktree {target}?").bold()));
|
||||
header.push(Line::from(
|
||||
"Only Codex-managed worktrees can be removed.".dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Remove".to_string(),
|
||||
description: Some("Remove the selected worktree.".to_string()),
|
||||
actions: vec![Box::new({
|
||||
move |tx| {
|
||||
tx.send(AppEvent::RemoveWorktree {
|
||||
target: target.clone(),
|
||||
force,
|
||||
delete_branch,
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
description: Some("Keep the worktree.".to_string()),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_worktree<'a>(
|
||||
entries: &'a [WorktreeInfo],
|
||||
target: &str,
|
||||
) -> Result<&'a WorktreeInfo, String> {
|
||||
let matches = entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match matches.as_slice() {
|
||||
[entry] => Ok(entry),
|
||||
[] => Err(format!("No worktree found matching '{target}'.")),
|
||||
_ => Err(format!(
|
||||
"Multiple worktrees match '{target}'; use a more specific name."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_label(source: WorktreeSource) -> &'static str {
|
||||
match source {
|
||||
WorktreeSource::Cli => "cli",
|
||||
WorktreeSource::App => "app",
|
||||
WorktreeSource::Legacy => "legacy",
|
||||
WorktreeSource::Git => "git",
|
||||
}
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
|
||||
let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
|
||||
a == b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ListSelectionView;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::tui::FrameRequester;
|
||||
use codex_worktree::DirtyState;
|
||||
use codex_worktree::WorktreeLocation;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn parse_new_with_flags() {
|
||||
assert_eq!(
|
||||
parse_worktree_slash_args("new fcoury/demo --base origin/main --dirty copy-tracked"),
|
||||
Ok(WorktreeSlashAction::Create {
|
||||
branch: "fcoury/demo".to_string(),
|
||||
base_ref: Some("origin/main".to_string()),
|
||||
dirty_policy: Some(DirtyPolicy::CopyTracked),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_switch_aliases_move() {
|
||||
assert_eq!(
|
||||
parse_worktree_slash_args("move fcoury/demo"),
|
||||
Ok(WorktreeSlashAction::Switch {
|
||||
target: "fcoury/demo".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_remove_with_flags() {
|
||||
assert_eq!(
|
||||
parse_worktree_slash_args("remove fcoury/demo --force --delete-branch"),
|
||||
Ok(WorktreeSlashAction::Remove {
|
||||
target: "fcoury/demo".to_string(),
|
||||
force: true,
|
||||
delete_branch: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_picker_snapshot() {
|
||||
let params = picker_params(
|
||||
vec![
|
||||
sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false),
|
||||
sample_info("codex", WorktreeSource::App, /*dirty*/ false),
|
||||
sample_info("main", WorktreeSource::Git, /*dirty*/ true),
|
||||
],
|
||||
Path::new("/repo/codex.fcoury-demo"),
|
||||
);
|
||||
insta::assert_snapshot!("worktree_picker", render_selection(params, /*width*/ 86));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_loading_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_loading",
|
||||
render_selection(
|
||||
loading_params(
|
||||
FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ false
|
||||
),
|
||||
/*width*/ 92
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_switching_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_switching",
|
||||
render_selection(
|
||||
switching_params(
|
||||
"fcoury/demo".to_string(),
|
||||
FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ false
|
||||
),
|
||||
/*width*/ 92
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_dirty_policy_prompt_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_dirty_policy_prompt",
|
||||
render_selection(
|
||||
dirty_policy_prompt_params("fcoury/demo".to_string(), /*base_ref*/ None),
|
||||
/*width*/ 82
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_remove_confirmation_snapshot() {
|
||||
insta::assert_snapshot!(
|
||||
"worktree_remove_confirmation",
|
||||
render_selection(
|
||||
remove_confirmation_params(
|
||||
"fcoury/demo".to_string(),
|
||||
/*force*/ false,
|
||||
/*delete_branch*/ false
|
||||
),
|
||||
/*width*/ 80
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_info(branch: &str, source: WorktreeSource, dirty: bool) -> WorktreeInfo {
|
||||
let path = PathBuf::from(format!("/repo/codex.{}", branch.replace('/', "-")));
|
||||
WorktreeInfo {
|
||||
id: "repo-id".to_string(),
|
||||
name: branch.to_string(),
|
||||
slug: branch.replace('/', "-"),
|
||||
source,
|
||||
location: match source {
|
||||
WorktreeSource::Cli => WorktreeLocation::Sibling,
|
||||
WorktreeSource::App | WorktreeSource::Legacy => WorktreeLocation::CodexHome,
|
||||
WorktreeSource::Git => WorktreeLocation::External,
|
||||
},
|
||||
repo_name: "codex".to_string(),
|
||||
repo_root: path.clone(),
|
||||
common_git_dir: PathBuf::from("/repo/codex/.git"),
|
||||
worktree_git_root: path.clone(),
|
||||
workspace_cwd: path,
|
||||
original_relative_cwd: PathBuf::new(),
|
||||
branch: Some(branch.to_string()),
|
||||
head: Some("abcdef".to_string()),
|
||||
owner_thread_id: None,
|
||||
metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"),
|
||||
dirty: DirtyState {
|
||||
has_staged_changes: false,
|
||||
has_unstaged_changes: dirty,
|
||||
has_untracked_files: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_selection(params: SelectionViewParams, width: u16) -> String {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(params, tx, RuntimeKeymap::defaults().list);
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let lines: Vec<String> = (0..area.height)
|
||||
.map(|row| {
|
||||
let mut line = String::new();
|
||||
for col in 0..area.width {
|
||||
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
||||
if symbol.is_empty() {
|
||||
line.push(' ');
|
||||
} else {
|
||||
line.push_str(symbol);
|
||||
}
|
||||
}
|
||||
line.trim_end().to_string()
|
||||
})
|
||||
.collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use serde::Serialize;
|
||||
|
||||
pub use dirty::DirtyPolicy;
|
||||
pub use dirty::DirtyState;
|
||||
pub use dirty::dirty_state;
|
||||
pub use manager::ensure_worktree;
|
||||
pub use manager::list_worktrees;
|
||||
pub use manager::remove_worktree;
|
||||
|
||||
Reference in New Issue
Block a user