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>(
157 terminal: &mut ratatui::Terminal<B>,
158 app: &mut ListApp,
159 rx: mpsc::Receiver<(usize, String)>,
160) -> Result<(), B::Error> {
161 terminal.draw(|f| app.render(f))?;
162
163 while let Ok((i, status)) = rx.recv() {
167 app.set_status(i, status);
168 terminal.draw(|f| app.render(f))?;
169 if app.is_complete() {
170 break;
171 }
172 }
173 Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use ratatui::backend::TestBackend;
183 use ratatui::Terminal;
184
185 fn sample_row(id: &str, status: &str) -> RowData {
186 RowData {
187 worktree_id: id.to_string(),
188 current_branch: id.to_string(),
189 status: status.to_string(),
190 age: "1d ago".to_string(),
191 rel_path: format!("wt/{}", id),
192 }
193 }
194
195 #[test]
196 fn skeleton_frame_shows_placeholder_for_all_rows() {
197 let app = ListApp::new(vec![
198 sample_row("feat/a", PLACEHOLDER),
199 sample_row("feat/b", PLACEHOLDER),
200 ]);
201 let backend = TestBackend::new(80, 6);
202 let mut terminal = Terminal::new(backend).unwrap();
203 terminal.draw(|f| app.render(f)).unwrap();
204 let buf = terminal.backend().buffer().clone();
205 let rendered = buffer_to_string(&buf);
206 assert!(rendered.contains("feat/a"));
207 assert!(rendered.contains("feat/b"));
208 assert!(rendered.contains(PLACEHOLDER));
209 assert!(!app.is_complete());
210 }
211
212 #[test]
213 fn complete_frame_shows_final_status() {
214 let app = ListApp::new(vec![
215 sample_row("feat/a", "clean"),
216 sample_row("feat/b", "modified"),
217 ]);
218 let backend = TestBackend::new(80, 6);
219 let mut terminal = Terminal::new(backend).unwrap();
220 terminal.draw(|f| app.render(f)).unwrap();
221 let buf = terminal.backend().buffer().clone();
222 let rendered = buffer_to_string(&buf);
223 assert!(rendered.contains("clean"));
224 assert!(rendered.contains("modified"));
225 assert!(app.is_complete());
226 }
227
228 #[test]
229 fn run_fills_statuses_from_channel() {
230 let mut app = ListApp::new(vec![
231 sample_row("feat/a", PLACEHOLDER),
232 sample_row("feat/b", PLACEHOLDER),
233 ]);
234 let backend = TestBackend::new(80, 6);
235 let mut terminal = Terminal::new(backend).unwrap();
236
237 let (tx, rx) = std::sync::mpsc::channel();
238 let h = std::thread::spawn(move || {
240 tx.send((0, "clean".to_string())).unwrap();
241 tx.send((1, "modified".to_string())).unwrap();
242 });
243
244 run(&mut terminal, &mut app, rx).expect("list_view::run failed");
245 h.join().expect("status producer thread panicked"); assert_eq!(app.rows()[0].status, "clean");
247 assert_eq!(app.rows()[1].status, "modified");
248 assert!(app.is_complete());
249 }
250
251 #[test]
252 fn run_exits_when_sender_drops_with_pending_rows() {
253 let mut app = ListApp::new(vec![
254 sample_row("feat/a", PLACEHOLDER),
255 sample_row("feat/b", PLACEHOLDER),
256 ]);
257 let backend = TestBackend::new(80, 6);
258 let mut terminal = Terminal::new(backend).unwrap();
259
260 let (tx, rx) = std::sync::mpsc::channel();
261 let h = std::thread::spawn(move || {
263 tx.send((0, "clean".to_string())).unwrap();
264 });
266
267 run(&mut terminal, &mut app, rx).expect("list_view::run failed");
268 h.join().expect("status producer thread panicked"); assert_eq!(app.rows()[0].status, "clean");
270 assert_eq!(app.rows()[1].status, PLACEHOLDER); assert!(!app.is_complete());
272 }
273
274 #[test]
277 fn run_then_finalize_replaces_pending_after_disconnect() {
278 let mut app = ListApp::new(vec![
279 sample_row("a", PLACEHOLDER),
280 sample_row("b", PLACEHOLDER),
281 ]);
282 let backend = TestBackend::new(80, 6);
283 let mut terminal = Terminal::new(backend).unwrap();
284 let (tx, rx) = std::sync::mpsc::channel();
285 let h = std::thread::spawn(move || {
286 tx.send((0, "clean".to_string())).unwrap();
287 });
289 run(&mut terminal, &mut app, rx).expect("status producer thread panicked");
290 h.join().expect("status producer thread panicked");
291 let changed = app.finalize_pending("unknown");
292 assert!(
293 changed,
294 "row 1 was still PLACEHOLDER, so changed must be true"
295 );
296 assert_eq!(app.rows()[0].status, "clean");
297 assert_eq!(app.rows()[1].status, "unknown");
298 }
299
300 #[test]
301 fn finalize_pending_replaces_placeholders() {
302 let mut app = ListApp::new(vec![
303 sample_row("feat/a", PLACEHOLDER),
304 sample_row("feat/b", "clean"),
305 ]);
306 let changed = app.finalize_pending("unknown");
307 assert!(changed, "feat/a was PLACEHOLDER so changed should be true");
308 assert_eq!(app.rows()[0].status, "unknown");
309 assert_eq!(app.rows()[1].status, "clean"); }
311
312 #[test]
314 fn finalize_pending_replaces_only_placeholders() {
315 let mut app = ListApp::new(vec![
316 sample_row("a", "clean"),
317 sample_row("b", PLACEHOLDER),
318 sample_row("c", "modified"),
319 ]);
320 let changed = app.finalize_pending("unknown");
321 assert!(
322 changed,
323 "should return true when placeholders were replaced"
324 );
325 let rows = app.rows();
326 assert_eq!(rows[0].status, "clean");
327 assert_eq!(rows[1].status, "unknown");
328 assert_eq!(rows[2].status, "modified");
329 }
330
331 #[test]
332 fn finalize_pending_returns_false_when_nothing_pending() {
333 let mut app = ListApp::new(vec![sample_row("a", "clean")]);
334 assert!(
335 !app.finalize_pending("unknown"),
336 "should return false when no placeholders present"
337 );
338 }
339
340 fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String {
341 let mut out = String::new();
342 let area = buf.area();
343 for y in 0..area.height {
344 for x in 0..area.width {
345 out.push_str(buf[(x, y)].symbol());
346 }
347 out.push('\n');
348 }
349 out
350 }
351}