Skip to main content

git_worktree_manager/tui/
list_view.rs

1//! `gw list` Inline Viewport view.
2
3use 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
11/// Three ASCII dots; portable across terminals lacking Unicode rendering.
12/// Do not change to U+2026 ('…'); equality checks elsewhere depend on this exact value.
13pub const PLACEHOLDER: &str = "...";
14
15#[derive(Debug, Clone)]
16pub struct RowData {
17    pub worktree_id: String,
18    pub current_branch: String,
19    // `status` uses owned String for simplicity; Cow<'static, str> would avoid
20    // the placeholder allocation but adds lifetime gymnastics. Deferred.
21    pub status: String, // PLACEHOLDER while pending
22    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    /// Read-only access to rows.
36    pub fn rows(&self) -> &[RowData] {
37        &self.rows
38    }
39
40    /// Update a row's status. Rejects PLACEHOLDER (use `finalize_pending` to bulk-reset).
41    /// In debug builds, calling with PLACEHOLDER trips a debug_assert.
42    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    /// Replace every row whose status equals `PLACEHOLDER` with `replacement`.
53    /// Called after the producer finishes (or panics) to ensure no placeholder
54    /// remains in the final output.
55    ///
56    /// After calling `finalize_pending`, all rows have non-PLACEHOLDER status;
57    /// `is_complete()` will then always return true. Calling `set_status` after
58    /// `finalize_pending` is undefined.
59    ///
60    /// Returns `true` if any rows were updated (i.e., had PLACEHOLDER status),
61    /// `false` if nothing changed — lets callers skip a redundant redraw.
62    #[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    /// Consume `self`, yielding the inner rows.
75    pub fn into_rows(self) -> Vec<RowData> {
76        self.rows
77    }
78
79    /// Returns true when every row has a non-PLACEHOLDER status.
80    ///
81    /// After [`finalize_pending`] all rows have non-PLACEHOLDER status,
82    /// so this returns `true`.
83    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        // #28: fixed proportional widths because the terminal width may change
120        // between draws (user resizes). The static layout used for non-TTY and
121        // narrow terminals computes column widths from data lengths instead —
122        // that's safe because it only runs once, after all statuses are known.
123        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
139/// Drive the Inline Viewport render loop, consuming `(row_index, status)`
140/// updates from `rx` until all rows are filled or the sender disconnects.
141///
142/// The caller is responsible for spawning the producer (typically a
143/// `rayon` par_iter inside a `std::thread::scope` that iterates worktrees
144/// in parallel and sends results) and for drawing the initial skeleton frame
145/// before calling `run` (e.g. `terminal.draw(|f| app.render(f))`). `run`
146/// draws the skeleton itself on its first iteration for backward-compat with
147/// test callers that do not pre-draw; the duplicate draw is harmless.
148///
149/// On return, `app.rows` contains final statuses. The viewport exits via
150/// `drop(terminal)` which leaves the final frame in the scrollback.
151///
152/// Returns `Result<(), B::Error>`. For `CrosstermBackend`, `B::Error = io::Error`,
153/// and `display.rs` converts it to `crate::error::Result` via the `#[from]` impl on
154/// `CwError::Io`. For `TestBackend`, `B::Error = Infallible`, and tests use
155/// `.expect(...)` to unwrap.
156pub 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    // Spec called for `recv_timeout(50ms)` for periodic refresh; we use blocking
164    // `recv()` because ratatui handles resize between draws automatically and
165    // every status message already triggers a redraw. No tick needed.
166    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    // rx.recv() returns Err when the sender drops — all statuses received or
174    // producer panicked. Either way the loop exits cleanly.
175
176    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        // #26: bind the handle and join so the thread is not silently leaked.
239        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"); // #26: propagate any thread panic to the test runner
246        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        // #27: bind the handle and join so the thread is not silently leaked.
262        let h = std::thread::spawn(move || {
263            tx.send((0, "clean".to_string())).unwrap();
264            // Drop tx without sending the second row — simulates panic.
265        });
266
267        run(&mut terminal, &mut app, rx).expect("list_view::run failed");
268        h.join().expect("status producer thread panicked"); // #27: propagate any thread panic to the test runner
269        assert_eq!(app.rows()[0].status, "clean");
270        assert_eq!(app.rows()[1].status, PLACEHOLDER); // still pending
271        assert!(!app.is_complete());
272    }
273
274    /// #27: finalize_pending correctly fills rows that were still PLACEHOLDER
275    /// when the producer disconnected (simulates panic or early exit).
276    #[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            // drop tx without sending row 1 — simulates producer panic
288        });
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"); // unchanged
310    }
311
312    // #36: tests for finalize_pending's bool return value (guarded redraw).
313    #[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}