Skip to content

Commit 2308dec

Browse files
authored
Optimize TUI startup with async session loading (#26)
* Load sessions asynchronously at startup * Address async loading review feedback --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 534e13f commit 2308dec

3 files changed

Lines changed: 121 additions & 27 deletions

File tree

src/app.rs

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,22 +126,26 @@ pub struct App {
126126
remote_log_receiver: Receiver<(String, String)>,
127127
/// Remote session IDs whose logs are currently loading in the background.
128128
remote_logs_loading: HashSet<String>,
129+
/// Sends completed session loads from background workers to the main loop.
130+
session_load_sender: Sender<(u64, Vec<CopilotSession>)>,
131+
/// Receives completed session loads without blocking startup or rendering.
132+
session_load_receiver: Receiver<(u64, Vec<CopilotSession>)>,
133+
/// Whether a session list load is currently in flight.
134+
sessions_loading: bool,
135+
/// Monotonically increasing token used to ignore stale background loads.
136+
session_load_generation: u64,
129137
}
130138

131139
impl App {
132140
pub fn new(copilot_dir: PathBuf, launch_dir: PathBuf) -> Self {
133-
let sessions = load_sessions(&copilot_dir);
134-
let flat_list = build_flat_list(&sessions, SessionFilter::All, "");
135-
136-
let selected_session = flat_list.first().copied();
137-
let cursor = 0;
138141
let (remote_log_sender, remote_log_receiver) = mpsc::channel();
142+
let (session_load_sender, session_load_receiver) = mpsc::channel();
139143

140144
App {
141-
sessions,
142-
flat_list,
143-
cursor,
144-
selected_session,
145+
sessions: Vec::new(),
146+
flat_list: Vec::new(),
147+
cursor: 0,
148+
selected_session: None,
145149
active_panel: Panel::Sessions,
146150
copilot_dir,
147151
launch_dir,
@@ -164,11 +168,36 @@ impl App {
164168
remote_log_sender,
165169
remote_log_receiver,
166170
remote_logs_loading: HashSet::new(),
171+
session_load_sender,
172+
session_load_receiver,
173+
sessions_loading: false,
174+
session_load_generation: 0,
167175
}
168176
}
169177

170178
pub fn reload(&mut self) {
171-
self.replace_sessions(load_sessions(&self.copilot_dir));
179+
self.session_load_generation = self.session_load_generation.wrapping_add(1);
180+
self.sessions_loading = true;
181+
let generation = self.session_load_generation;
182+
let copilot_dir = self.copilot_dir.clone();
183+
let sender = self.session_load_sender.clone();
184+
std::thread::spawn(move || {
185+
let sessions = load_sessions(&copilot_dir);
186+
let _ = sender.send((generation, sessions));
187+
});
188+
}
189+
190+
pub fn poll_session_loads(&mut self) {
191+
while let Ok((generation, sessions)) = self.session_load_receiver.try_recv() {
192+
if generation == self.session_load_generation {
193+
self.sessions_loading = false;
194+
self.replace_sessions(sessions);
195+
}
196+
}
197+
}
198+
199+
pub fn is_loading_sessions(&self) -> bool {
200+
self.sessions_loading
172201
}
173202

174203
pub fn refresh_statuses(&mut self) -> bool {
@@ -263,8 +292,10 @@ impl App {
263292
.find(|session| !baseline.contains(&session.id))
264293
.map(|session| session.id.clone())?;
265294

295+
self.session_load_generation = self.session_load_generation.wrapping_add(1);
266296
self.new_session_reload_baseline = None;
267297
self.replace_sessions(sessions);
298+
self.sessions_loading = false;
268299
Some(new_session_id)
269300
}
270301

@@ -493,7 +524,7 @@ impl App {
493524
let sender = self.remote_log_sender.clone();
494525
std::thread::spawn(move || {
495526
let log = load_remote_task_log(&id);
496-
drop(sender.send((id, log)));
527+
let _ = sender.send((id, log));
497528
});
498529
}
499530

@@ -769,6 +800,7 @@ mod tests {
769800

770801
fn app_with_sessions(sessions: Vec<CopilotSession>) -> App {
771802
let (remote_log_sender, remote_log_receiver) = mpsc::channel();
803+
let (session_load_sender, session_load_receiver) = mpsc::channel();
772804
let flat_list = build_flat_list(&sessions, SessionFilter::All, "");
773805
let selected_session = flat_list.first().copied();
774806
App {
@@ -798,6 +830,10 @@ mod tests {
798830
remote_log_sender,
799831
remote_log_receiver,
800832
remote_logs_loading: HashSet::new(),
833+
session_load_sender,
834+
session_load_receiver,
835+
sessions_loading: false,
836+
session_load_generation: 0,
801837
}
802838
}
803839

@@ -1060,6 +1096,43 @@ mod tests {
10601096
assert_eq!(app.directory_suggestion_cursor(), Some(1));
10611097
}
10621098

1099+
#[test]
1100+
fn app_new_starts_with_empty_non_loading_session_list() {
1101+
let app = App::new(PathBuf::from("/tmp/copilot"), PathBuf::from("/tmp"));
1102+
1103+
assert!(app.sessions.is_empty());
1104+
assert!(app.flat_list.is_empty());
1105+
assert_eq!(app.selected_session, None);
1106+
assert!(!app.is_loading_sessions());
1107+
}
1108+
1109+
#[test]
1110+
fn reload_starts_background_session_load() {
1111+
let mut app = App::new(PathBuf::from("/tmp/copilot"), PathBuf::from("/tmp"));
1112+
1113+
app.reload();
1114+
1115+
assert!(app.is_loading_sessions());
1116+
}
1117+
1118+
#[test]
1119+
fn poll_session_loads_replaces_sessions() {
1120+
let mut app = App::new(PathBuf::from("/tmp/copilot"), PathBuf::from("/tmp"));
1121+
app.sessions_loading = true;
1122+
app.session_load_sender
1123+
.send((
1124+
app.session_load_generation,
1125+
vec![session("local", SessionSource::Local)],
1126+
))
1127+
.unwrap();
1128+
1129+
app.poll_session_loads();
1130+
1131+
assert!(!app.is_loading_sessions());
1132+
assert_eq!(app.sessions.len(), 1);
1133+
assert_eq!(app.selected_session, Some(0));
1134+
}
1135+
10631136
#[test]
10641137
fn moving_cursor_to_remote_task_does_not_load_log() {
10651138
let mut app = app_with_sessions(vec![

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ fn main() -> Result<()> {
3636
let backend = CrosstermBackend::new(io::stdout());
3737
let mut terminal = Terminal::new(backend).context("Failed to create terminal")?;
3838
terminal.clear()?;
39+
app.reload();
3940

4041
let result = run_event_loop(&mut terminal, &mut app);
4142

@@ -70,6 +71,7 @@ where
7071
let mut last_status_poll = Instant::now();
7172

7273
loop {
74+
app.poll_session_loads();
7375
app.poll_remote_log_loads();
7476
resize_embedded_terminal(app, terminal.size()?);
7577
terminal.draw(|f| ui::draw(f, app))?;

src/ui.rs

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,13 @@ fn draw_sessions_panel(f: &mut Frame, app: &mut App, area: Rect) {
119119
Style::default().fg(MUTED_COLOR)
120120
};
121121

122+
let title = if app.is_loading_sessions() {
123+
" Sessions (loading…) "
124+
} else {
125+
" Sessions "
126+
};
122127
let block = Block::default()
123-
.title(" Sessions ")
128+
.title(title)
124129
.title_style(
125130
Style::default()
126131
.fg(ACCENT_COLOR)
@@ -135,18 +140,22 @@ fn draw_sessions_panel(f: &mut Frame, app: &mut App, area: Rect) {
135140
let list_area = inner;
136141

137142
if app.flat_list.is_empty() {
138-
let title = if app.sessions.is_empty() {
143+
let title = if app.is_loading_sessions() {
144+
"Loading Copilot sessions…"
145+
} else if app.sessions.is_empty() {
139146
"No Copilot sessions found."
140147
} else {
141148
"No sessions match the active filters."
142149
};
150+
let description = if app.is_loading_sessions() {
151+
"Reading local sessions and Remote agent tasks."
152+
} else {
153+
"No sessions match the current filters."
154+
};
143155
let msg = Paragraph::new(Text::from(vec![
144156
Line::from(Span::styled(title, Style::default().fg(MUTED_COLOR))),
145157
Line::from(Span::raw("")),
146-
Line::from(Span::styled(
147-
"No sessions match the current filters.",
148-
Style::default().fg(MUTED_COLOR),
149-
)),
158+
Line::from(Span::styled(description, Style::default().fg(MUTED_COLOR))),
150159
]))
151160
.style(Style::default().bg(SURFACE_COLOR))
152161
.alignment(Alignment::Center);
@@ -416,16 +425,21 @@ fn draw_detail_panel(f: &mut Frame, app: &mut App, area: Rect) {
416425
.border_style(border_style);
417426
let inner = block.inner(area);
418427
f.render_widget(block, area);
419-
let msg = Paragraph::new(Text::from(vec![
420-
Line::from(Span::styled(
428+
let (title, description) = if app.is_loading_sessions() {
429+
(
430+
"Loading session details…",
431+
"Sessions will appear here as soon as they are ready.",
432+
)
433+
} else {
434+
(
421435
"Select a session with j/k + Enter",
422-
Style::default().fg(MUTED_COLOR),
423-
)),
424-
Line::from(Span::raw("")),
425-
Line::from(Span::styled(
426436
"Press [o] to open a live terminal session",
427-
Style::default().fg(MUTED_COLOR),
428-
)),
437+
)
438+
};
439+
let msg = Paragraph::new(Text::from(vec![
440+
Line::from(Span::styled(title, Style::default().fg(MUTED_COLOR))),
441+
Line::from(Span::raw("")),
442+
Line::from(Span::styled(description, Style::default().fg(MUTED_COLOR))),
429443
]))
430444
.style(Style::default().bg(SURFACE_COLOR))
431445
.alignment(Alignment::Center);
@@ -1061,11 +1075,16 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
10611075
}
10621076

10631077
fn footer_text(app: &App) -> String {
1064-
footer_shortcuts(app)
1078+
let shortcuts = footer_shortcuts(app)
10651079
.into_iter()
10661080
.map(|(action, key)| format!("{action}: {key}"))
10671081
.collect::<Vec<_>>()
1068-
.join(" ")
1082+
.join(" ");
1083+
if app.is_loading_sessions() {
1084+
format!("Loading sessions… {shortcuts}")
1085+
} else {
1086+
shortcuts
1087+
}
10691088
}
10701089

10711090
fn footer_shortcuts(app: &App) -> Vec<(&'static str, &'static str)> {

0 commit comments

Comments
 (0)