travelagent 1.11.1

Agent-first TUI code review tool
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! Full-file viewer pane state, added in v1.11.0.
//!
//! The diff pane shows only the changed/context lines of a file. Sometimes a
//! reviewer wants the *whole* file in front of them — to read surrounding
//! code, jump to a definition the diff doesn't include, or read a doc/README
//! end-to-end. `:view` (or the `t` key) toggles the main content area from the
//! diff renderer to this viewer; toggling again returns to the diff.
//!
//! Within the viewer there's a second axis: **raw** (syntax-highlighted source,
//! the default and the only mode for code) vs **rendered** (a small allowlist
//! of formats — currently just Markdown — drawn with the in-tree
//! [`crate::ui::markdown::render_markdown`]). `T` (or `:raw` / `:render`) flips
//! it. Rendered view is purely read-only and will never host comments.
//!
//! ## Forward-compatibility with review comments
//!
//! The viewer is built so that adding "comment from the viewer" later is a drop-in:
//! the cursor tracks a **real 1-based source line number** (not an index into
//! `line_annotations` the way `DiffState` does), because the review engine keys
//! comments by `(path, u32 line, LineSide)`. A future `c`-in-viewer handler can
//! therefore call `enter_comment_mode(false, Some((self.cursor_line_no(), LineSide::New)))`
//! with no rework here. Rendered (markdown) view does **not** support comments —
//! there's no stable source-line ↔ rendered-row mapping — so the comment hook
//! is only ever wired from raw view.
//!
//! ## State vs. content
//!
//! Like [`crate::app::LiveModeState`], this struct caches the file body it's
//! displaying ([`ViewerContent`]) and is responsible for invalidating that
//! cache when the selected file changes. The render code reads the cache; if
//! it's stale or missing for the current path, the caller refreshes it (the
//! filesystem read lives in `App`, not here, to keep this struct free of I/O).

use std::path::{Path, PathBuf};

/// Which rendering the viewer uses for the current file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewerRender {
    /// Syntax-highlighted source. The default; the only mode for non-renderable
    /// formats. Comment-capable (once the comment hook is wired).
    Raw,
    /// Format-specific rendered view (currently Markdown only). Read-only.
    Rendered,
}

/// Cached full-file body for the viewer, tagged with the path it belongs to so
/// a file switch can be detected and the cache invalidated.
#[derive(Debug, Clone)]
pub struct ViewerContent {
    /// Display path of the file this content belongs to (repo-relative, the
    /// same key `App::current_file_path` yields).
    pub path: PathBuf,
    /// The file split into lines (no trailing newlines). Index `i` is source
    /// line `i + 1`.
    pub lines: Vec<String>,
}

/// Viewer pane state. See module docs.
#[derive(Debug)]
pub struct ViewerPaneState {
    /// `true` when the viewer is showing instead of the diff.
    active: bool,
    /// Raw vs rendered. Persists across diff↔viewer toggles so re-opening the
    /// viewer restores the last sub-mode (subject to format support).
    render: ViewerRender,
    /// Top visible source-line index (0-based into `content.lines`). The
    /// viewer's analogue of `DiffState::scroll_offset`.
    scroll_offset: usize,
    /// Cursor position as a 0-based index into `content.lines`. Tracks a real
    /// source line (see module docs re: forward-compat for comments).
    cursor: usize,
    /// Horizontal scroll, used when `wrap_lines` is false.
    scroll_x: usize,
    /// Soft-wrap long lines. Mirrors `DiffState::wrap_lines` (default on).
    wrap_lines: bool,
    /// Visible viewport height in rows, written during render and read by the
    /// scroll/cursor methods (the standard "render measures, handlers consume"
    /// pattern used by `DiffState`/`HelpState`).
    viewport_height: usize,
    /// Visible viewport width in columns, written during render.
    viewport_width: usize,
    /// Total source lines, written during render so clamping doesn't need the
    /// cache borrowed.
    total_lines: usize,
    /// Cached body of the file currently displayed. `None` until the first
    /// refresh; invalidated when the selected file changes.
    content: Option<ViewerContent>,
}

impl Default for ViewerPaneState {
    fn default() -> Self {
        Self {
            active: false,
            render: ViewerRender::Raw,
            scroll_offset: 0,
            cursor: 0,
            scroll_x: 0,
            wrap_lines: true,
            viewport_height: 0,
            viewport_width: 0,
            total_lines: 0,
            content: None,
        }
    }
}

impl ViewerPaneState {
    /// File extensions (lowercase, no dot) that support the rendered view.
    /// Currently Markdown only; extend deliberately — each new entry needs a
    /// renderer that maps the format to ratatui `Line`s.
    const RENDERABLE_EXTENSIONS: &'static [&'static str] = &["md", "markdown"];

    // ---- intent / mode ----

    /// `true` when the viewer should render instead of the diff.
    pub fn is_active(&self) -> bool {
        self.active
    }

    /// Show the viewer. Resets scroll/cursor to the top so opening the viewer
    /// always starts at the file head; the content cache is left intact (the
    /// caller refreshes it for the current path).
    pub fn activate(&mut self) {
        self.active = true;
        self.scroll_offset = 0;
        self.cursor = 0;
        self.scroll_x = 0;
    }

    /// Return to the diff pane. Mode (raw/rendered) and cache are preserved so
    /// a later re-open is cheap and remembers the sub-mode.
    pub fn deactivate(&mut self) {
        self.active = false;
    }

    /// Flip diff↔viewer; returns the new `active` state so the handler can pick
    /// the right status message.
    pub fn toggle_active(&mut self) -> bool {
        if self.active {
            self.deactivate();
        } else {
            self.activate();
        }
        self.active
    }

    /// Current render mode.
    pub fn render_mode(&self) -> ViewerRender {
        self.render
    }

    /// Whether `path` supports the rendered view (extension allowlist).
    pub fn is_renderable(path: &Path) -> bool {
        path.extension()
            .and_then(|e| e.to_str())
            .map(|e| e.to_ascii_lowercase())
            .is_some_and(|e| Self::RENDERABLE_EXTENSIONS.contains(&e.as_str()))
    }

    /// Switch to raw view. Always allowed.
    pub fn set_raw(&mut self) {
        self.render = ViewerRender::Raw;
    }

    /// Request rendered view for `path`. Succeeds (returns `true`) only when the
    /// format is renderable; otherwise leaves the mode unchanged and returns
    /// `false` so the caller can surface "markdown only".
    pub fn set_rendered(&mut self, path: &Path) -> bool {
        if Self::is_renderable(path) {
            self.render = ViewerRender::Rendered;
            true
        } else {
            false
        }
    }

    /// Toggle raw↔rendered for `path`. Returns the new mode on success, or
    /// `None` when a switch to rendered was requested for an unsupported format
    /// (mode unchanged — caller shows "markdown only").
    pub fn toggle_render(&mut self, path: &Path) -> Option<ViewerRender> {
        match self.render {
            ViewerRender::Rendered => {
                self.set_raw();
                Some(ViewerRender::Raw)
            }
            ViewerRender::Raw => {
                if self.set_rendered(path) {
                    Some(ViewerRender::Rendered)
                } else {
                    None
                }
            }
        }
    }

    /// Force the mode back to raw if the current file can't be rendered. Called
    /// after a file switch so a markdown→code change doesn't leave the viewer
    /// stuck in a rendered mode the new file can't satisfy.
    pub fn coerce_render_for(&mut self, path: &Path) {
        if self.render == ViewerRender::Rendered && !Self::is_renderable(path) {
            self.render = ViewerRender::Raw;
        }
    }

    // ---- content cache ----

    /// Whether the cache holds content for `path`.
    pub fn has_content_for(&self, path: &Path) -> bool {
        self.content.as_ref().is_some_and(|c| c.path == path)
    }

    /// Borrow the cached content, if any.
    pub fn content(&self) -> Option<&ViewerContent> {
        self.content.as_ref()
    }

    /// Replace the cached content and reset scroll/cursor to the top. Called by
    /// the caller after reading the file for a (possibly new) path.
    pub fn set_content(&mut self, path: PathBuf, lines: Vec<String>) {
        self.total_lines = lines.len();
        self.content = Some(ViewerContent { path, lines });
        self.scroll_offset = 0;
        self.cursor = 0;
        self.scroll_x = 0;
    }

    /// Drop the cache (e.g. on file switch) so the next render refreshes it.
    pub fn invalidate_content(&mut self) {
        self.content = None;
        self.total_lines = 0;
    }

    // ---- scroll / cursor ----

    /// 1-based source line number under the cursor. Used by the (future)
    /// viewer comment hook; the engine keys comments by this number.
    pub fn cursor_line_no(&self) -> u32 {
        (self.cursor as u32).saturating_add(1)
    }

    pub fn scroll_offset(&self) -> usize {
        self.scroll_offset
    }

    pub fn scroll_x(&self) -> usize {
        self.scroll_x
    }

    pub fn cursor(&self) -> usize {
        self.cursor
    }

    pub fn wrap_lines(&self) -> bool {
        self.wrap_lines
    }

    /// Toggle soft-wrap (mirrors the diff pane's wrap toggle).
    pub fn toggle_wrap(&mut self) {
        self.wrap_lines = !self.wrap_lines;
        if self.wrap_lines {
            self.scroll_x = 0;
        }
    }

    /// Record viewport metrics measured during render.
    pub fn set_viewport(&mut self, height: usize, width: usize) {
        self.viewport_height = height;
        self.viewport_width = width;
    }

    /// Largest valid line index, or 0 when empty.
    fn last_line(&self) -> usize {
        self.total_lines.saturating_sub(1)
    }

    /// Move the cursor down `n` lines, clamping to the last line and keeping it
    /// visible.
    pub fn cursor_down(&mut self, n: usize) {
        self.cursor = (self.cursor + n).min(self.last_line());
        self.ensure_cursor_visible();
    }

    /// Move the cursor up `n` lines, clamping at the top.
    pub fn cursor_up(&mut self, n: usize) {
        self.cursor = self.cursor.saturating_sub(n);
        self.ensure_cursor_visible();
    }

    /// Jump to the first line.
    pub fn cursor_to_top(&mut self) {
        self.cursor = 0;
        self.scroll_offset = 0;
    }

    /// Jump to the last line.
    pub fn cursor_to_bottom(&mut self) {
        self.cursor = self.last_line();
        self.ensure_cursor_visible();
    }

    /// Horizontal scroll right (no-op while wrapping).
    pub fn scroll_right(&mut self, n: usize) {
        if !self.wrap_lines {
            self.scroll_x = self.scroll_x.saturating_add(n);
        }
    }

    /// Horizontal scroll left (no-op while wrapping).
    pub fn scroll_left(&mut self, n: usize) {
        if !self.wrap_lines {
            self.scroll_x = self.scroll_x.saturating_sub(n);
        }
    }

    /// Clamp `scroll_offset` so the cursor stays within the viewport. Mirrors
    /// `App::ensure_cursor_visible` for the diff pane but works in raw
    /// source-line space (so it's exact only in non-wrapped raw view; in
    /// wrapped/rendered view it's a close-enough best effort, same trade-off
    /// the help popup makes).
    pub fn ensure_cursor_visible(&mut self) {
        if self.viewport_height == 0 {
            return;
        }
        if self.cursor < self.scroll_offset {
            self.scroll_offset = self.cursor;
        } else if self.cursor >= self.scroll_offset + self.viewport_height {
            self.scroll_offset = self.cursor.saturating_sub(self.viewport_height - 1);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn lines(n: usize) -> Vec<String> {
        (0..n).map(|i| format!("line {i}")).collect()
    }

    #[test]
    fn default_is_inactive_raw() {
        let v = ViewerPaneState::default();
        assert!(!v.is_active());
        assert_eq!(v.render_mode(), ViewerRender::Raw);
    }

    #[test]
    fn toggle_active_round_trips_and_resets_scroll() {
        let mut v = ViewerPaneState::default();
        v.set_content(PathBuf::from("a.rs"), lines(100));
        v.set_viewport(10, 80);
        v.cursor_down(50);
        assert!(v.scroll_offset() > 0);

        assert!(v.toggle_active()); // → active
        assert_eq!(v.scroll_offset(), 0, "activate resets scroll to top");
        assert_eq!(v.cursor(), 0);

        assert!(!v.toggle_active()); // → inactive
        assert!(!v.is_active());
    }

    #[test]
    fn rendered_only_for_markdown() {
        assert!(ViewerPaneState::is_renderable(Path::new("README.md")));
        assert!(ViewerPaneState::is_renderable(Path::new("docs/x.markdown")));
        assert!(ViewerPaneState::is_renderable(Path::new("X.MD"))); // case-insensitive
        assert!(!ViewerPaneState::is_renderable(Path::new("main.rs")));
        assert!(!ViewerPaneState::is_renderable(Path::new("Makefile")));
    }

    #[test]
    fn set_rendered_rejects_non_markdown() {
        let mut v = ViewerPaneState::default();
        assert!(!v.set_rendered(Path::new("main.rs")));
        assert_eq!(
            v.render_mode(),
            ViewerRender::Raw,
            "mode unchanged on reject"
        );
        assert!(v.set_rendered(Path::new("notes.md")));
        assert_eq!(v.render_mode(), ViewerRender::Rendered);
    }

    #[test]
    fn toggle_render_signals_unsupported() {
        let mut v = ViewerPaneState::default();
        // Raw → rendered on a .rs file is refused (None), mode stays raw.
        assert_eq!(v.toggle_render(Path::new("main.rs")), None);
        assert_eq!(v.render_mode(), ViewerRender::Raw);
        // Raw → rendered on markdown succeeds.
        assert_eq!(
            v.toggle_render(Path::new("a.md")),
            Some(ViewerRender::Rendered)
        );
        // Rendered → raw always succeeds (path irrelevant).
        assert_eq!(v.toggle_render(Path::new("a.md")), Some(ViewerRender::Raw));
    }

    #[test]
    fn coerce_render_drops_rendered_for_code() {
        let mut v = ViewerPaneState::default();
        assert!(v.set_rendered(Path::new("a.md")));
        // Switching to a code file while in rendered mode must fall back to raw.
        v.coerce_render_for(Path::new("main.rs"));
        assert_eq!(v.render_mode(), ViewerRender::Raw);
        // Markdown → markdown keeps rendered.
        assert!(v.set_rendered(Path::new("b.md")));
        v.coerce_render_for(Path::new("c.md"));
        assert_eq!(v.render_mode(), ViewerRender::Rendered);
    }

    #[test]
    fn content_cache_keyed_by_path() {
        let mut v = ViewerPaneState::default();
        assert!(!v.has_content_for(Path::new("a.rs")));
        v.set_content(PathBuf::from("a.rs"), lines(3));
        assert!(v.has_content_for(Path::new("a.rs")));
        assert!(!v.has_content_for(Path::new("b.rs")));
        v.invalidate_content();
        assert!(!v.has_content_for(Path::new("a.rs")));
    }

    #[test]
    fn cursor_line_no_is_one_based() {
        let mut v = ViewerPaneState::default();
        v.set_content(PathBuf::from("a.rs"), lines(10));
        assert_eq!(v.cursor_line_no(), 1, "cursor at index 0 → line 1");
        v.set_viewport(10, 80);
        v.cursor_down(4);
        assert_eq!(v.cursor_line_no(), 5);
    }

    #[test]
    fn cursor_clamps_at_bounds() {
        let mut v = ViewerPaneState::default();
        v.set_content(PathBuf::from("a.rs"), lines(5));
        v.set_viewport(10, 80);
        v.cursor_up(3); // already at top
        assert_eq!(v.cursor(), 0);
        v.cursor_down(100); // past end
        assert_eq!(v.cursor(), 4, "clamps to last line index");
        v.cursor_to_top();
        assert_eq!(v.cursor(), 0);
        v.cursor_to_bottom();
        assert_eq!(v.cursor(), 4);
    }

    #[test]
    fn ensure_cursor_visible_scrolls_viewport() {
        let mut v = ViewerPaneState::default();
        v.set_content(PathBuf::from("a.rs"), lines(100));
        v.set_viewport(10, 80);
        v.cursor_down(20);
        // Cursor at line 20, viewport height 10 → offset pins cursor to last row.
        assert!(v.scroll_offset() <= 20);
        assert!(v.scroll_offset() + 10 > 20, "cursor within viewport");
        v.cursor_up(20);
        assert_eq!(v.scroll_offset(), 0, "scrolling back up returns to top");
    }

    #[test]
    fn horizontal_scroll_only_when_unwrapped() {
        let mut v = ViewerPaneState::default();
        assert!(v.wrap_lines());
        v.scroll_right(4);
        assert_eq!(v.scroll_x(), 0, "wrapped → horizontal scroll is a no-op");
        v.toggle_wrap();
        assert!(!v.wrap_lines());
        v.scroll_right(4);
        assert_eq!(v.scroll_x(), 4);
        v.scroll_left(10);
        assert_eq!(v.scroll_x(), 0, "clamps at 0");
        // Re-enabling wrap resets horizontal scroll.
        v.scroll_right(6);
        v.toggle_wrap();
        assert!(v.wrap_lines());
        assert_eq!(v.scroll_x(), 0);
    }
}