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 `std::io::Result<()>`. In `display.rs`, the `#[from]` impl on
153/// `CwError::Io` converts this to `crate::error::Result` via `From` — no
154/// manual mapping is needed at the call site.
155pub 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    // Spec called for `recv_timeout(50ms)` for periodic refresh; we use blocking
163    // `recv()` because ratatui handles resize between draws automatically and
164    // every status message already triggers a redraw. No tick needed.
165    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    // rx.recv() returns Err when the sender drops — all statuses received or
173    // producer panicked. Either way the loop exits cleanly.
174
175    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        // #26: bind the handle and join so the thread is not silently leaked.
238        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"); // #26: propagate any thread panic to the test runner
245        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        // #27: bind the handle and join so the thread is not silently leaked.
261        let h = std::thread::spawn(move || {
262            tx.send((0, "clean".to_string())).unwrap();
263            // Drop tx without sending the second row — simulates panic.
264        });
265
266        run(&mut terminal, &mut app, rx).expect("list_view::run failed");
267        h.join().expect("status producer thread panicked"); // #27: propagate any thread panic to the test runner
268        assert_eq!(app.rows()[0].status, "clean");
269        assert_eq!(app.rows()[1].status, PLACEHOLDER); // still pending
270        assert!(!app.is_complete());
271    }
272
273    /// #27: finalize_pending correctly fills rows that were still PLACEHOLDER
274    /// when the producer disconnected (simulates panic or early exit).
275    #[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            // drop tx without sending row 1 — simulates producer panic
287        });
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"); // unchanged
309    }
310
311    // #36: tests for finalize_pending's bool return value (guarded redraw).
312    #[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}