git_worktree_manager/tui/
list_view.rs1use std::sync::mpsc;
4
5use ratatui::layout::Constraint;
6use ratatui::text::Span;
7use ratatui::widgets::{Block, Borders, Cell, Row, Table};
8
9use crate::tui::style;
10
11pub const PLACEHOLDER: &str = "...";
14
15#[derive(Debug, Clone)]
16pub struct RowData {
17 pub worktree_id: String,
18 pub current_branch: String,
19 pub status: String, pub age: String,
23 pub rel_path: String,
24}
25
26pub struct ListApp {
27 rows: Vec<RowData>,
28}
29
30impl ListApp {
31 pub fn new(rows: Vec<RowData>) -> Self {
32 Self { rows }
33 }
34
35 pub fn rows(&self) -> &[RowData] {
37 &self.rows
38 }
39
40 pub(crate) fn set_status(&mut self, i: usize, status: String) {
43 debug_assert_ne!(
44 status, PLACEHOLDER,
45 "set_status should never restore the placeholder"
46 );
47 if let Some(r) = self.rows.get_mut(i) {
48 r.status = status;
49 }
50 }
51
52 #[must_use = "ignoring whether any rows changed may cause redundant or missing redraws"]
63 pub fn finalize_pending(&mut self, replacement: &str) -> bool {
64 let mut changed = false;
65 for r in self.rows.iter_mut() {
66 if r.status == PLACEHOLDER {
67 r.status = replacement.to_string();
68 changed = true;
69 }
70 }
71 changed
72 }
73
74 pub fn into_rows(self) -> Vec<RowData> {
76 self.rows
77 }
78
79 pub fn is_complete(&self) -> bool {
84 self.rows.iter().all(|r| r.status != PLACEHOLDER)
85 }
86
87 pub fn render(&self, frame: &mut ratatui::Frame<'_>) {
88 let header = Row::new(vec![
89 Cell::from("WORKTREE"),
90 Cell::from("BRANCH"),
91 Cell::from("STATUS"),
92 Cell::from("AGE"),
93 Cell::from("PATH"),
94 ])
95 .style(style::header_style());
96
97 let body: Vec<Row> = self
98 .rows
99 .iter()
100 .map(|r| {
101 let status_cell = if r.status == PLACEHOLDER {
102 Cell::from(Span::styled(PLACEHOLDER, style::placeholder_style()))
103 } else {
104 Cell::from(Span::styled(
105 r.status.as_str(),
106 style::status_style(&r.status),
107 ))
108 };
109 Row::new(vec![
110 Cell::from(r.worktree_id.as_str()),
111 Cell::from(r.current_branch.as_str()),
112 status_cell,
113 Cell::from(r.age.as_str()),
114 Cell::from(r.rel_path.as_str()),
115 ])
116 })
117 .collect();
118
119 let widths = [
124 Constraint::Percentage(20),
125 Constraint::Percentage(25),
126 Constraint::Length(10),
127 Constraint::Length(10),
128 Constraint::Percentage(35),
129 ];
130
131 let table = Table::new(body, widths)
132 .header(header)
133 .block(Block::default().borders(Borders::NONE));
134
135 frame.render_widget(table, frame.area());
136 }
137}
138
139pub fn run<B: ratatui::backend::Backend>(
156 terminal: &mut ratatui::Terminal<B>,
157 app: &mut ListApp,
158 rx: mpsc::Receiver<(usize, String)>,
159) -> std::io::Result<()> {
160 terminal.draw(|f| app.render(f))?;
161
162 while let Ok((i, status)) = rx.recv() {
166 app.set_status(i, status);
167 terminal.draw(|f| app.render(f))?;
168 if app.is_complete() {
169 break;
170 }
171 }
172 Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use ratatui::backend::TestBackend;
182 use ratatui::Terminal;
183
184 fn sample_row(id: &str, status: &str) -> RowData {
185 RowData {
186 worktree_id: id.to_string(),
187 current_branch: id.to_string(),
188 status: status.to_string(),
189 age: "1d ago".to_string(),
190 rel_path: format!("wt/{}", id),
191 }
192 }
193
194 #[test]
195 fn skeleton_frame_shows_placeholder_for_all_rows() {
196 let app = ListApp::new(vec![
197 sample_row("feat/a", PLACEHOLDER),
198 sample_row("feat/b", PLACEHOLDER),
199 ]);
200 let backend = TestBackend::new(80, 6);
201 let mut terminal = Terminal::new(backend).unwrap();
202 terminal.draw(|f| app.render(f)).unwrap();
203 let buf = terminal.backend().buffer().clone();
204 let rendered = buffer_to_string(&buf);
205 assert!(rendered.contains("feat/a"));
206 assert!(rendered.contains("feat/b"));
207 assert!(rendered.contains(PLACEHOLDER));
208 assert!(!app.is_complete());
209 }
210
211 #[test]
212 fn complete_frame_shows_final_status() {
213 let app = ListApp::new(vec![
214 sample_row("feat/a", "clean"),
215 sample_row("feat/b", "modified"),
216 ]);
217 let backend = TestBackend::new(80, 6);
218 let mut terminal = Terminal::new(backend).unwrap();
219 terminal.draw(|f| app.render(f)).unwrap();
220 let buf = terminal.backend().buffer().clone();
221 let rendered = buffer_to_string(&buf);
222 assert!(rendered.contains("clean"));
223 assert!(rendered.contains("modified"));
224 assert!(app.is_complete());
225 }
226
227 #[test]
228 fn run_fills_statuses_from_channel() {
229 let mut app = ListApp::new(vec![
230 sample_row("feat/a", PLACEHOLDER),
231 sample_row("feat/b", PLACEHOLDER),
232 ]);
233 let backend = TestBackend::new(80, 6);
234 let mut terminal = Terminal::new(backend).unwrap();
235
236 let (tx, rx) = std::sync::mpsc::channel();
237 let h = std::thread::spawn(move || {
239 tx.send((0, "clean".to_string())).unwrap();
240 tx.send((1, "modified".to_string())).unwrap();
241 });
242
243 run(&mut terminal, &mut app, rx).expect("list_view::run failed");
244 h.join().expect("status producer thread panicked"); assert_eq!(app.rows()[0].status, "clean");
246 assert_eq!(app.rows()[1].status, "modified");
247 assert!(app.is_complete());
248 }
249
250 #[test]
251 fn run_exits_when_sender_drops_with_pending_rows() {
252 let mut app = ListApp::new(vec![
253 sample_row("feat/a", PLACEHOLDER),
254 sample_row("feat/b", PLACEHOLDER),
255 ]);
256 let backend = TestBackend::new(80, 6);
257 let mut terminal = Terminal::new(backend).unwrap();
258
259 let (tx, rx) = std::sync::mpsc::channel();
260 let h = std::thread::spawn(move || {
262 tx.send((0, "clean".to_string())).unwrap();
263 });
265
266 run(&mut terminal, &mut app, rx).expect("list_view::run failed");
267 h.join().expect("status producer thread panicked"); assert_eq!(app.rows()[0].status, "clean");
269 assert_eq!(app.rows()[1].status, PLACEHOLDER); assert!(!app.is_complete());
271 }
272
273 #[test]
276 fn run_then_finalize_replaces_pending_after_disconnect() {
277 let mut app = ListApp::new(vec![
278 sample_row("a", PLACEHOLDER),
279 sample_row("b", PLACEHOLDER),
280 ]);
281 let backend = TestBackend::new(80, 6);
282 let mut terminal = Terminal::new(backend).unwrap();
283 let (tx, rx) = std::sync::mpsc::channel();
284 let h = std::thread::spawn(move || {
285 tx.send((0, "clean".to_string())).unwrap();
286 });
288 run(&mut terminal, &mut app, rx).expect("status producer thread panicked");
289 h.join().expect("status producer thread panicked");
290 let changed = app.finalize_pending("unknown");
291 assert!(
292 changed,
293 "row 1 was still PLACEHOLDER, so changed must be true"
294 );
295 assert_eq!(app.rows()[0].status, "clean");
296 assert_eq!(app.rows()[1].status, "unknown");
297 }
298
299 #[test]
300 fn finalize_pending_replaces_placeholders() {
301 let mut app = ListApp::new(vec![
302 sample_row("feat/a", PLACEHOLDER),
303 sample_row("feat/b", "clean"),
304 ]);
305 let changed = app.finalize_pending("unknown");
306 assert!(changed, "feat/a was PLACEHOLDER so changed should be true");
307 assert_eq!(app.rows()[0].status, "unknown");
308 assert_eq!(app.rows()[1].status, "clean"); }
310
311 #[test]
313 fn finalize_pending_replaces_only_placeholders() {
314 let mut app = ListApp::new(vec![
315 sample_row("a", "clean"),
316 sample_row("b", PLACEHOLDER),
317 sample_row("c", "modified"),
318 ]);
319 let changed = app.finalize_pending("unknown");
320 assert!(
321 changed,
322 "should return true when placeholders were replaced"
323 );
324 let rows = app.rows();
325 assert_eq!(rows[0].status, "clean");
326 assert_eq!(rows[1].status, "unknown");
327 assert_eq!(rows[2].status, "modified");
328 }
329
330 #[test]
331 fn finalize_pending_returns_false_when_nothing_pending() {
332 let mut app = ListApp::new(vec![sample_row("a", "clean")]);
333 assert!(
334 !app.finalize_pending("unknown"),
335 "should return false when no placeholders present"
336 );
337 }
338
339 fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String {
340 let mut out = String::new();
341 let area = buf.area();
342 for y in 0..area.height {
343 for x in 0..area.width {
344 out.push_str(buf[(x, y)].symbol());
345 }
346 out.push('\n');
347 }
348 out
349 }
350}