fix(tui): keep worktree switching responsive

This commit is contained in:
Felipe Coury
2026-05-06 23:58:02 -03:00
parent 250390cb76
commit 4f3955ff91
7 changed files with 548 additions and 77 deletions
+15 -1
View File
@@ -205,7 +205,21 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::SwitchToWorktree { target } => {
self.switch_to_worktree_target(tui, app_server, target)
self.begin_switch_to_worktree_target(tui, target);
tui.frame_requester().schedule_frame();
}
AppEvent::SwitchToWorktreeAfterLoading { target } => {
self.switch_to_worktree_target_after_loading(tui, app_server, target)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::WorktreeSessionReady {
info,
config,
forked,
result,
} => {
self.on_worktree_session_ready(tui, app_server, info, config, forked, result)
.await;
tui.frame_requester().schedule_frame();
}
+380 -47
View File
@@ -1,15 +1,41 @@
//! App-layer handlers for the worktree TUI flow.
use codex_protocol::ThreadId;
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::Path;
use std::path::PathBuf;
use std::time::Duration;
use super::*;
const WORKTREE_SWITCH_RENDER_DELAY: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorktreeSwitchMode {
StartFresh,
Fork(ThreadId),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorktreeSessionTransition {
Forked,
Started,
}
impl WorktreeSessionTransition {
fn message_prefix(self) -> &'static str {
match self {
WorktreeSessionTransition::Forked => "Forked into",
WorktreeSessionTransition::Started => "Started session in",
}
}
}
impl App {
pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) {
if self.remote_app_server_url.is_some() {
@@ -97,7 +123,7 @@ impl App {
.branch
.clone()
.unwrap_or_else(|| resolution.info.name.clone());
self.show_worktree_switching_view(tui, target).await;
self.show_worktree_switching_view(tui, target);
self.switch_to_worktree_info(tui, app_server, resolution.info)
.await;
for warning in warnings {
@@ -105,7 +131,18 @@ impl App {
}
}
pub(super) async fn switch_to_worktree_target(
pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, 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());
self.defer_switch_to_worktree_target(target);
}
pub(super) async fn switch_to_worktree_target_after_loading(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
@@ -117,7 +154,6 @@ impl App {
);
return;
}
self.show_worktree_switching_view(tui, target.clone()).await;
let entries = match self.list_current_repo_worktrees() {
Ok(entries) => entries,
Err(err) => {
@@ -264,59 +300,142 @@ impl App {
};
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) => {
let mode = self.worktree_switch_mode().await;
self.spawn_worktree_session_request(app_server, info, config, mode);
tui.frame_requester().schedule_frame();
}
pub(super) async fn on_worktree_session_ready(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
info: WorktreeInfo,
config: Config,
forked: bool,
result: Result<AppServerStartedThread, String>,
) {
match result {
Ok(started) => {
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, started, /*initial_user_message*/ None,
)
.await
{
self.show_worktree_error(
"Failed to fork current session into worktree.".to_string(),
"Failed to attach to worktree thread.".to_string(),
err.to_string(),
);
} else {
let transition = if forked {
WorktreeSessionTransition::Forked
} else {
WorktreeSessionTransition::Started
};
self.add_worktree_session_message(&info, transition);
}
}
} 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(),
);
}
Err(err) => {
let summary = if forked {
"Failed to fork current session into worktree."
} else {
"Failed to start session in worktree."
};
self.show_worktree_error(summary.to_string(), err);
}
}
tui.frame_requester().schedule_frame();
}
async fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
fn spawn_worktree_session_request(
&self,
app_server: &AppServerSession,
info: WorktreeInfo,
config: Config,
mode: WorktreeSwitchMode,
) {
let request_handle = app_server.request_handle();
let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf);
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let forked = matches!(mode, WorktreeSwitchMode::Fork(_));
let result = match mode {
WorktreeSwitchMode::Fork(thread_id) => {
crate::app_server_session::fork_thread_with_request_handle(
request_handle,
config.clone(),
thread_id,
remote_cwd_override,
)
.await
}
WorktreeSwitchMode::StartFresh => {
crate::app_server_session::start_thread_with_request_handle(
request_handle,
config.clone(),
remote_cwd_override,
)
.await
}
}
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::WorktreeSessionReady {
info,
config,
forked,
result,
});
});
}
fn add_worktree_session_message(
&mut self,
info: &WorktreeInfo,
transition: WorktreeSessionTransition,
) {
let (message, hint) = worktree_session_message(info, transition);
self.chat_widget.add_info_message(message, Some(hint));
}
async fn worktree_switch_mode(&self) -> WorktreeSwitchMode {
let Some(thread_id) = self.current_displayed_thread_id() else {
return WorktreeSwitchMode::StartFresh;
};
if self
.session_for_thread(thread_id)
.await
.as_ref()
.is_some_and(Self::session_has_materialized_rollout)
{
WorktreeSwitchMode::Fork(thread_id)
} else {
WorktreeSwitchMode::StartFresh
}
}
async fn session_for_thread(&self, thread_id: ThreadId) -> Option<ThreadSessionState> {
if self.primary_thread_id == Some(thread_id)
&& let Some(session) = self.primary_session_configured.clone()
{
return Some(session);
}
let channel = self.thread_event_channels.get(&thread_id)?;
let store = channel.store.lock().await;
store.session.clone()
}
fn session_has_materialized_rollout(session: &ThreadSessionState) -> bool {
session
.rollout_path
.as_ref()
.is_some_and(|rollout_path| rollout_path.exists())
}
fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
self.chat_widget
.show_selection_view(crate::worktree::switching_params(
target,
@@ -324,7 +443,14 @@ impl App {
self.config.animations,
));
tui.frame_requester().schedule_frame();
tokio::task::yield_now().await;
}
fn defer_switch_to_worktree_target(&self, target: String) {
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(WORKTREE_SWITCH_RENDER_DELAY).await;
app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { target });
});
}
fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool {
@@ -350,3 +476,210 @@ impl App {
.update_search_dir(self.config.cwd.to_path_buf());
}
}
fn worktree_session_message(
info: &WorktreeInfo,
transition: WorktreeSessionTransition,
) -> (String, String) {
let worktree_name = info.branch.as_deref().unwrap_or(info.name.as_str());
let state = if info.dirty.is_dirty() {
"dirty"
} else {
"clean"
};
let source = crate::worktree::source_label(info.source);
(
format!(
"{} {source} worktree {worktree_name} · {state} · {}",
transition.message_prefix(),
info.repo_name
),
info.workspace_cwd.display().to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::AskForApproval;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_worktree::DirtyState;
use codex_worktree::WorktreeLocation;
use tempfile::TempDir;
#[tokio::test]
async fn worktree_switch_mode_starts_fresh_without_current_thread() {
let app = crate::app::test_support::make_test_app().await;
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::StartFresh
);
}
#[tokio::test]
async fn worktree_switch_mode_starts_fresh_for_unmaterialized_primary_rollout() {
let temp_dir = TempDir::new().expect("temp dir");
let thread_id = ThreadId::new();
let missing_rollout_path = temp_dir.path().join("missing-rollout.jsonl");
let session = test_thread_session(
thread_id,
temp_dir.path().join("repo"),
missing_rollout_path,
);
let mut app = crate::app::test_support::make_test_app().await;
app.primary_thread_id = Some(thread_id);
app.active_thread_id = Some(thread_id);
app.primary_session_configured = Some(session);
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::StartFresh
);
}
#[tokio::test]
async fn worktree_switch_mode_forks_materialized_primary_rollout() {
let temp_dir = TempDir::new().expect("temp dir");
let thread_id = ThreadId::new();
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\\n").expect("write rollout");
let session = test_thread_session(thread_id, temp_dir.path().join("repo"), rollout_path);
let mut app = crate::app::test_support::make_test_app().await;
app.primary_thread_id = Some(thread_id);
app.active_thread_id = Some(thread_id);
app.primary_session_configured = Some(session);
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::Fork(thread_id)
);
}
#[tokio::test]
async fn worktree_switch_mode_uses_active_non_primary_thread_session() {
let temp_dir = TempDir::new().expect("temp dir");
let primary_thread_id = ThreadId::new();
let active_thread_id = ThreadId::new();
let active_rollout_path = temp_dir.path().join("active-rollout.jsonl");
std::fs::write(&active_rollout_path, "{}\\n").expect("write rollout");
let active_session = test_thread_session(
active_thread_id,
temp_dir.path().join("active"),
active_rollout_path,
);
let mut app = crate::app::test_support::make_test_app().await;
app.primary_thread_id = Some(primary_thread_id);
app.active_thread_id = Some(active_thread_id);
app.primary_session_configured = Some(test_thread_session(
primary_thread_id,
temp_dir.path().join("primary"),
temp_dir.path().join("missing-primary-rollout.jsonl"),
));
app.thread_event_channels.insert(
active_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
active_session,
Vec::new(),
),
);
assert_eq!(
app.worktree_switch_mode().await,
WorktreeSwitchMode::Fork(active_thread_id)
);
}
#[test]
fn worktree_session_message_describes_forked_workspace() {
let info = test_worktree_info(
WorktreeSource::Cli,
Some("fcoury/demo".to_string()),
/*dirty*/ false,
);
assert_eq!(
worktree_session_message(&info, WorktreeSessionTransition::Forked),
(
"Forked into cli worktree fcoury/demo · clean · codex".to_string(),
"/repo/codex.fcoury-demo".to_string()
)
);
}
#[test]
fn worktree_session_message_describes_started_dirty_workspace() {
let info = test_worktree_info(
WorktreeSource::App,
/*branch*/ None,
/*dirty*/ true,
);
assert_eq!(
worktree_session_message(&info, WorktreeSessionTransition::Started),
(
"Started session in app worktree app-worktree · dirty · codex".to_string(),
"/repo/codex.fcoury-demo".to_string()
)
);
}
fn test_thread_session(
thread_id: ThreadId,
cwd: PathBuf,
rollout_path: PathBuf,
) -> ThreadSessionState {
ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: AbsolutePathBuf::try_from(cwd).expect("absolute cwd"),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_path),
}
}
fn test_worktree_info(
source: WorktreeSource,
branch: Option<String>,
dirty: bool,
) -> WorktreeInfo {
let path = PathBuf::from("/repo/codex.fcoury-demo");
WorktreeInfo {
id: "repo-id".to_string(),
name: "app-worktree".to_string(),
slug: "fcoury-demo".to_string(),
source,
location: WorktreeLocation::Sibling,
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,
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,
},
}
}
}
+15
View File
@@ -34,10 +34,12 @@ use codex_utils_approval_presets::ApprovalPreset;
use codex_worktree::DirtyPolicy;
use crate::app_command::AppCommand;
use crate::app_server_session::AppServerStartedThread;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::UserMessage;
use crate::legacy_core::config::Config;
use codex_app_server_protocol::AskForApproval;
use codex_config::types::ApprovalsReviewer;
use codex_features::Feature;
@@ -213,6 +215,19 @@ pub(crate) enum AppEvent {
target: String,
},
/// Continue switching into an existing worktree after the loading view has rendered.
SwitchToWorktreeAfterLoading {
target: String,
},
/// Result of starting or forking a session in a worktree.
WorktreeSessionReady {
info: codex_worktree::WorktreeInfo,
config: Config,
forked: bool,
result: Result<AppServerStartedThread, String>,
},
/// Show the filesystem path for an existing worktree.
ShowWorktreePath {
target: String,
+97
View File
@@ -119,6 +119,7 @@ use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use uuid::Uuid;
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
color_eyre::eyre::eyre!("{context}: {err}")
@@ -166,6 +167,7 @@ impl ThreadParamsMode {
}
}
#[derive(Debug)]
pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
@@ -969,6 +971,101 @@ impl AppServerSession {
}
}
pub(crate) async fn start_thread_with_request_handle(
request_handle: AppServerRequestHandle,
config: Config,
remote_cwd_override: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
let response: ThreadStartResponse = request_handle
.request_typed(ClientRequest::ThreadStart {
request_id: worktree_request_id("worktree-thread-start"),
params: thread_start_params_from_config(
&config,
thread_params_mode,
remote_cwd_override.as_deref(),
/*session_start_source*/ None,
),
})
.await
.map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?;
started_thread_from_start_response(response, &config, thread_params_mode).await
}
pub(crate) async fn fork_thread_with_request_handle(
request_handle: AppServerRequestHandle,
config: Config,
thread_id: ThreadId,
remote_cwd_override: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
let thread_params_mode = thread_params_mode_from_request_handle(&request_handle);
let response: ThreadForkResponse = request_handle
.request_typed(ClientRequest::ThreadFork {
request_id: worktree_request_id("worktree-thread-fork"),
params: thread_fork_params_from_config(
config.clone(),
thread_id,
thread_params_mode,
remote_cwd_override.as_deref(),
),
})
.await
.map_err(|err| bootstrap_request_error("thread/fork failed during TUI bootstrap", err))?;
let fork_parent_title = fork_parent_title_from_request_handle(
&request_handle,
response.thread.forked_from_id.as_deref(),
)
.await;
let mut started =
started_thread_from_fork_response(response, &config, thread_params_mode).await?;
started.session.fork_parent_title = fork_parent_title;
Ok(started)
}
fn worktree_request_id(prefix: &str) -> RequestId {
RequestId::String(format!("{prefix}-{}", Uuid::new_v4()))
}
fn thread_params_mode_from_request_handle(
request_handle: &AppServerRequestHandle,
) -> ThreadParamsMode {
match request_handle {
AppServerRequestHandle::InProcess(_) => ThreadParamsMode::Embedded,
AppServerRequestHandle::Remote(_) => ThreadParamsMode::Remote,
}
}
async fn fork_parent_title_from_request_handle(
request_handle: &AppServerRequestHandle,
forked_from_id: Option<&str>,
) -> Option<String> {
let forked_from_id = forked_from_id?;
let forked_from_id = match ThreadId::from_string(forked_from_id) {
Ok(thread_id) => thread_id,
Err(err) => {
tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
return None;
}
};
match request_handle
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
request_id: worktree_request_id("worktree-thread-read"),
params: ThreadReadParams {
thread_id: forked_from_id.to_string(),
include_turns: false,
},
})
.await
{
Ok(thread) => thread.thread.name,
Err(err) => {
tracing::warn!("Failed to read fork parent metadata from app server: {err}");
None
}
}
}
fn thread_realtime_start_params(
thread_id: ThreadId,
transport: Option<ThreadRealtimeStartTransport>,
+4 -16
View File
@@ -6,19 +6,14 @@
use super::*;
use crate::bottom_pane::status_line_from_segments;
use crate::branch_summary;
use crate::motion::ACTIVITY_SPINNER_INTERVAL;
use crate::motion::activity_spinner_frame_at;
use crate::status::format_tokens_compact;
/// Items shown in the terminal title when the user has not configured a
/// custom selection. Intentionally minimal: activity indicator + project name.
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"];
/// Braille-pattern dot-spinner frames for the terminal title animation.
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Time between spinner frame advances in the terminal title.
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
/// Time between action-required blink phases in the terminal title.
const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1);
@@ -362,7 +357,7 @@ impl ChatWidget {
}
self.should_animate_terminal_title_spinner_with_selections(selections)
.then_some(TERMINAL_TITLE_SPINNER_INTERVAL)
.then_some(ACTIVITY_SPINNER_INTERVAL)
}
pub(super) fn request_status_line_branch_refresh(&mut self) {
@@ -816,14 +811,7 @@ impl ChatWidget {
return None;
}
Some(self.terminal_title_spinner_frame_at(now).to_string())
}
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
let frame_index =
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
Some(activity_spinner_frame_at(self.terminal_title_animation_origin, now).to_string())
}
fn terminal_title_uses_activity(&self) -> bool {
+29
View File
@@ -3,6 +3,7 @@
//! Callers choose an explicit reduced-motion fallback here instead of reaching
//! directly for time-varying spinner or shimmer helpers.
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Stylize;
@@ -10,6 +11,10 @@ use ratatui::text::Span;
use crate::shimmer::shimmer_spans;
const ACTIVITY_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(crate) const ACTIVITY_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MotionMode {
Animated,
@@ -59,6 +64,12 @@ pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec<Span<'sta
}
}
pub(crate) fn activity_spinner_frame_at(origin: Instant, now: Instant) -> &'static str {
let elapsed = now.saturating_duration_since(origin);
let frame_index = (elapsed.as_millis() / ACTIVITY_SPINNER_INTERVAL.as_millis()) as usize;
ACTIVITY_SPINNER_FRAMES[frame_index % ACTIVITY_SPINNER_FRAMES.len()]
}
fn animated_activity_indicator(start_time: Option<Instant>) -> Span<'static> {
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
@@ -117,6 +128,24 @@ mod tests {
);
}
#[test]
fn activity_spinner_frame_advances_and_wraps() {
let origin = Instant::now();
assert_eq!(activity_spinner_frame_at(origin, origin), "⠋");
assert_eq!(
activity_spinner_frame_at(origin, origin + ACTIVITY_SPINNER_INTERVAL),
"⠙"
);
assert_eq!(
activity_spinner_frame_at(
origin,
origin + ACTIVITY_SPINNER_INTERVAL * ACTIVITY_SPINNER_FRAMES.len() as u32,
),
"⠋"
);
}
#[test]
fn animation_primitives_are_only_used_by_motion_module() {
let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap();
+8 -13
View File
@@ -1,5 +1,4 @@
use std::path::Path;
use std::time::Duration;
use std::time::Instant;
use codex_worktree::DirtyPolicy;
@@ -19,9 +18,8 @@ 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::motion::ACTIVITY_SPINNER_INTERVAL;
use crate::motion::activity_spinner_frame_at;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
@@ -29,7 +27,6 @@ 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,
@@ -62,19 +59,17 @@ impl Renderable for WorktreeLoadingHeader {
return;
}
let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled);
if self.animations_enabled {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
.schedule_frame_in(ACTIVITY_SPINNER_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);
if self.animations_enabled {
loading_spans.push(activity_spinner_frame_at(self.started_at, Instant::now()).into());
loading_spans.push(" ".into());
} else {
loading_spans.push("•".dim());
loading_spans.push(" ".into());
}
loading_spans.push(self.status.clone().dim());