Skip to content

Commit f470abf

Browse files
committed
⚡️ Make TUI startup non-blocking with async initialization
Move git status loading and companion service initialization to async tasks that run concurrently after the TUI renders. This eliminates startup delays caused by blocking git operations and file watcher setup. The async approach: - Load git status in background via spawn_blocking - Initialize companion service (file watcher) asynchronously - Defer auto-generation until git status data arrives - Lazy-load explore mode file tree on mode switch This ensures users see the TUI immediately rather than waiting for potentially slow git operations to complete.
1 parent d697850 commit f470abf

File tree

5 files changed

+275
-85
lines changed

5 files changed

+275
-85
lines changed

src/studio/app/mod.rs

Lines changed: 253 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,30 @@ pub enum IrisTaskResult {
9393
GlobalLogLoaded {
9494
entries: Vec<crate::studio::state::FileLogEntry>,
9595
},
96+
/// Git status loaded (async initialization)
97+
GitStatusLoaded(Box<GitStatusData>),
98+
/// Companion service initialized (async)
99+
CompanionReady(Box<CompanionInitData>),
100+
}
101+
102+
/// Data from async git status loading
103+
#[derive(Debug, Clone)]
104+
pub struct GitStatusData {
105+
pub branch: String,
106+
pub staged_files: Vec<std::path::PathBuf>,
107+
pub modified_files: Vec<std::path::PathBuf>,
108+
pub untracked_files: Vec<std::path::PathBuf>,
109+
pub commits_ahead: usize,
110+
pub commits_behind: usize,
111+
pub staged_diff: Option<String>,
112+
}
113+
114+
/// Data from async companion initialization
115+
pub struct CompanionInitData {
116+
/// The companion service
117+
pub service: crate::companion::CompanionService,
118+
/// Display data for the UI
119+
pub display: super::state::CompanionSessionDisplay,
96120
}
97121

98122
/// Type of content update triggered by chat
@@ -339,6 +363,9 @@ impl StudioApp {
339363
DataType::ReleaseNotesCommits => {
340364
self.update_release_notes_data(from_ref, to_ref);
341365
}
366+
DataType::ExploreFiles => {
367+
self.update_explore_file_tree();
368+
}
342369
}
343370
}
344371

@@ -400,9 +427,8 @@ impl StudioApp {
400427
};
401428
self.state.git_status = status;
402429

403-
// Update file trees for components
430+
// Update file trees for components (explore tree is lazy-loaded on mode switch)
404431
self.update_commit_file_tree();
405-
self.update_explore_file_tree();
406432
self.update_review_file_tree();
407433

408434
// Load diffs into diff view
@@ -709,6 +735,138 @@ impl StudioApp {
709735
});
710736
}
711737

738+
/// Load git status asynchronously (for fast TUI startup)
739+
fn load_git_status_async(&self) {
740+
let Some(repo) = &self.state.repo else {
741+
return;
742+
};
743+
744+
let tx = self.iris_result_tx.clone();
745+
let repo_path = repo.repo_path().clone();
746+
747+
tokio::spawn(async move {
748+
let result = tokio::task::spawn_blocking(move || {
749+
use crate::git::GitRepo;
750+
751+
// Open repo once and gather all data
752+
let repo = GitRepo::new(&repo_path)?;
753+
754+
let branch = repo.get_current_branch().unwrap_or_default();
755+
let files_info = repo.extract_files_info(false).ok();
756+
let unstaged = repo.get_unstaged_files().ok();
757+
let untracked = repo.get_untracked_files().unwrap_or_default();
758+
let (commits_ahead, commits_behind) = repo.get_ahead_behind();
759+
let staged_diff = repo.get_staged_diff_full().ok();
760+
761+
let staged_files: Vec<std::path::PathBuf> = files_info
762+
.as_ref()
763+
.map(|f| {
764+
f.staged_files
765+
.iter()
766+
.map(|s| s.path.clone().into())
767+
.collect()
768+
})
769+
.unwrap_or_default();
770+
771+
let modified_files: Vec<std::path::PathBuf> = unstaged
772+
.as_ref()
773+
.map(|f| f.iter().map(|s| s.path.clone().into()).collect())
774+
.unwrap_or_default();
775+
776+
let untracked_files: Vec<std::path::PathBuf> = untracked
777+
.into_iter()
778+
.map(std::path::PathBuf::from)
779+
.collect();
780+
781+
Ok::<_, anyhow::Error>(GitStatusData {
782+
branch,
783+
staged_files,
784+
modified_files,
785+
untracked_files,
786+
commits_ahead,
787+
commits_behind,
788+
staged_diff,
789+
})
790+
})
791+
.await;
792+
793+
match result {
794+
Ok(Ok(data)) => {
795+
let _ = tx.send(IrisTaskResult::GitStatusLoaded(Box::new(data)));
796+
}
797+
Ok(Err(e)) => {
798+
tracing::warn!("Failed to load git status: {}", e);
799+
}
800+
Err(e) => {
801+
tracing::warn!("Git status task panicked: {}", e);
802+
}
803+
}
804+
});
805+
}
806+
807+
/// Load companion service asynchronously for fast TUI startup
808+
fn load_companion_async(&self) {
809+
let Some(repo) = &self.state.repo else {
810+
return;
811+
};
812+
813+
let tx = self.iris_result_tx.clone();
814+
let repo_path = repo.repo_path().clone();
815+
let branch = repo
816+
.get_current_branch()
817+
.unwrap_or_else(|_| "main".to_string());
818+
819+
tokio::spawn(async move {
820+
let result = tokio::task::spawn_blocking(move || {
821+
use crate::companion::{BranchMemory, CompanionService};
822+
use super::state::CompanionSessionDisplay;
823+
824+
// Create companion service (this is the slow part - file watcher setup)
825+
let service = CompanionService::new(repo_path, &branch)?;
826+
827+
// Load or create branch memory
828+
let mut branch_mem = service
829+
.load_branch_memory(&branch)
830+
.ok()
831+
.flatten()
832+
.unwrap_or_else(|| BranchMemory::new(branch.clone()));
833+
834+
// Get welcome message before recording visit
835+
let welcome = branch_mem.welcome_message();
836+
837+
// Record this visit
838+
branch_mem.record_visit();
839+
840+
// Save updated branch memory
841+
if let Err(e) = service.save_branch_memory(&branch_mem) {
842+
tracing::warn!("Failed to save branch memory: {}", e);
843+
}
844+
845+
let display = CompanionSessionDisplay {
846+
watcher_active: service.has_watcher(),
847+
welcome_message: welcome.clone(),
848+
welcome_shown_at: welcome.map(|_| std::time::Instant::now()),
849+
..Default::default()
850+
};
851+
852+
Ok::<_, anyhow::Error>(CompanionInitData { service, display })
853+
})
854+
.await;
855+
856+
match result {
857+
Ok(Ok(data)) => {
858+
let _ = tx.send(IrisTaskResult::CompanionReady(Box::new(data)));
859+
}
860+
Ok(Err(e)) => {
861+
tracing::warn!("Failed to initialize companion: {}", e);
862+
}
863+
Err(e) => {
864+
tracing::warn!("Companion init task panicked: {}", e);
865+
}
866+
}
867+
});
868+
}
869+
712870
/// Run the TUI application
713871
pub fn run(&mut self) -> Result<ExitResult> {
714872
// Install panic hook to ensure terminal is restored on panic
@@ -748,43 +906,14 @@ impl StudioApp {
748906
&mut self,
749907
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
750908
) -> Result<ExitResult> {
751-
// Refresh git status on start
752-
let _ = self.refresh_git_status();
909+
// Start async git status loading for fast TUI startup
910+
self.state.git_status_loading = true;
911+
self.load_git_status_async();
753912

754-
// Set initial mode based on repo state (only if no explicit mode was set)
755-
let current_mode = if self.explicit_mode_set {
756-
self.state.active_mode
757-
} else {
758-
let suggested_mode = self.state.suggest_initial_mode();
759-
self.state.switch_mode(suggested_mode);
760-
suggested_mode
761-
};
913+
// Start async companion initialization (file watcher setup is slow)
914+
self.load_companion_async();
762915

763-
// Auto-generate content based on initial mode
764-
match current_mode {
765-
Mode::Commit => {
766-
if self.state.git_status.has_staged() {
767-
self.auto_generate_commit();
768-
}
769-
}
770-
Mode::PR => {
771-
self.update_pr_data(None, None);
772-
self.auto_generate_pr();
773-
}
774-
Mode::Review => {
775-
self.update_review_data(None, None);
776-
self.auto_generate_review();
777-
}
778-
Mode::Changelog => {
779-
self.update_changelog_data(None, None);
780-
self.auto_generate_changelog();
781-
}
782-
Mode::ReleaseNotes => {
783-
self.update_release_notes_data(None, None);
784-
self.auto_generate_release_notes();
785-
}
786-
Mode::Explore => {}
787-
}
916+
// Note: Auto-generation happens in apply_git_status_data() after async load completes
788917

789918
loop {
790919
// Process any pending file log load (deferred from initialization)
@@ -1072,12 +1201,99 @@ impl StudioApp {
10721201
IrisTaskResult::GlobalLogLoaded { entries } => {
10731202
StudioEvent::GlobalLogLoaded { entries }
10741203
}
1204+
1205+
IrisTaskResult::GitStatusLoaded(data) => {
1206+
// Apply git status data directly (not through reducer)
1207+
self.apply_git_status_data(*data);
1208+
continue; // Already handled
1209+
}
1210+
1211+
IrisTaskResult::CompanionReady(data) => {
1212+
// Apply companion data directly
1213+
self.state.companion = Some(data.service);
1214+
self.state.companion_display = data.display;
1215+
self.state.mark_dirty();
1216+
tracing::info!("Companion service initialized asynchronously");
1217+
continue; // Already handled
1218+
}
10751219
};
10761220

10771221
self.push_event(event);
10781222
}
10791223
}
10801224

1225+
/// Apply git status data from async loading
1226+
fn apply_git_status_data(&mut self, data: GitStatusData) {
1227+
use super::components::diff_view::parse_diff;
1228+
1229+
// Update git status
1230+
self.state.git_status = super::state::GitStatus {
1231+
branch: data.branch,
1232+
staged_count: data.staged_files.len(),
1233+
staged_files: data.staged_files,
1234+
modified_count: data.modified_files.len(),
1235+
modified_files: data.modified_files,
1236+
untracked_count: data.untracked_files.len(),
1237+
untracked_files: data.untracked_files,
1238+
commits_ahead: data.commits_ahead,
1239+
commits_behind: data.commits_behind,
1240+
};
1241+
self.state.git_status_loading = false;
1242+
1243+
// Update file trees
1244+
self.update_commit_file_tree();
1245+
self.update_review_file_tree();
1246+
1247+
// Load diffs from staged diff text
1248+
if let Some(diff_text) = data.staged_diff {
1249+
let diffs = parse_diff(&diff_text);
1250+
self.state.modes.commit.diff_view.set_diffs(diffs);
1251+
}
1252+
1253+
// Sync initial file selection with diff view
1254+
if let Some(path) = self.state.modes.commit.file_tree.selected_path() {
1255+
self.state.modes.commit.diff_view.select_file_by_path(&path);
1256+
}
1257+
1258+
// If no explicit mode was set, switch to suggested mode
1259+
if !self.explicit_mode_set {
1260+
let suggested = self.state.suggest_initial_mode();
1261+
if suggested != self.state.active_mode {
1262+
self.state.switch_mode(suggested);
1263+
}
1264+
}
1265+
1266+
// Auto-generate based on mode
1267+
match self.state.active_mode {
1268+
Mode::Commit => {
1269+
if self.state.git_status.has_staged() {
1270+
self.auto_generate_commit();
1271+
}
1272+
}
1273+
Mode::Review => {
1274+
self.update_review_data(None, None);
1275+
self.auto_generate_review();
1276+
}
1277+
Mode::PR => {
1278+
self.update_pr_data(None, None);
1279+
self.auto_generate_pr();
1280+
}
1281+
Mode::Changelog => {
1282+
self.update_changelog_data(None, None);
1283+
self.auto_generate_changelog();
1284+
}
1285+
Mode::ReleaseNotes => {
1286+
self.update_release_notes_data(None, None);
1287+
self.auto_generate_release_notes();
1288+
}
1289+
Mode::Explore => {
1290+
self.update_explore_file_tree();
1291+
}
1292+
}
1293+
1294+
self.state.mark_dirty();
1295+
}
1296+
10811297
/// Spawn fire-and-forget status message generation using the fast model
10821298
///
10831299
/// This spawns an async task that generates witty status messages while

src/studio/components/file_tree.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ impl FileTreeState {
187187
}
188188
}
189189

190+
/// Check if tree is empty
191+
pub fn is_empty(&self) -> bool {
192+
self.root.is_empty()
193+
}
194+
190195
/// Set root nodes
191196
pub fn set_root(&mut self, root: Vec<TreeNode>) {
192197
self.root = root;

src/studio/events.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,8 @@ pub enum DataType {
376376
PRDiff,
377377
ChangelogCommits,
378378
ReleaseNotesCommits,
379+
/// Explore mode file tree (lazy-loaded on mode switch)
380+
ExploreFiles,
379381
}
380382

381383
/// Modal types

src/studio/reducer/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,14 @@ pub fn reduce(
111111
});
112112
}
113113
Mode::Explore => {
114-
// Explore mode loads files on demand
114+
// Load explore file tree if not already loaded
115+
if state.modes.explore.file_tree.is_empty() {
116+
effects.push(SideEffect::LoadData {
117+
data_type: DataType::ExploreFiles,
118+
from_ref: None,
119+
to_ref: None,
120+
});
121+
}
115122
}
116123
}
117124
}

0 commit comments

Comments
 (0)