Skip to main content

seal_tui/
model.rs

1//! Application state model
2
3use std::cell::{Cell, RefCell};
4use std::collections::{HashMap, HashSet};
5use std::time::Instant;
6
7use crate::command::CommandSpec;
8use crate::config::UiConfig;
9use crate::db::{Comment, ReviewDetail, ReviewSummary, ThreadDetail, ThreadSummary};
10use crate::diff::ParsedDiff;
11use crate::syntax::{HighlightSpan, Highlighter};
12use crate::theme::Theme;
13
14/// File content for displaying context when no diff is available.
15///
16/// When populated from seal's windowed content, `start_line` indicates
17/// the 1-based line number of the first element in `lines`.
18/// When populated from a full file read, `start_line` is 1.
19#[derive(Debug, Clone)]
20pub struct FileContent {
21    pub lines: Vec<String>,
22    /// 1-based line number of `lines[0]`. Defaults to 1 for full files.
23    pub start_line: i64,
24}
25
26/// Cached data for a file in the review stream
27pub struct FileCacheEntry {
28    pub diff: Option<ParsedDiff>,
29    pub file_content: Option<FileContent>,
30    pub highlighted_lines: Vec<Vec<HighlightSpan>>,
31    /// Syntax highlights indexed by file line number (for orphaned thread context).
32    /// Only populated when both `diff` and `file_content` are present.
33    pub file_highlighted_lines: Vec<Vec<HighlightSpan>>,
34}
35
36/// Current screen/view
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum Screen {
39    #[default]
40    ReviewList,
41    ReviewDetail,
42}
43
44/// Which pane has focus
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum Focus {
47    #[default]
48    ReviewList,
49    FileSidebar,
50    DiffPane,
51    ThreadExpanded,
52    CommandPalette,
53    Commenting,
54}
55
56/// What the command palette is showing
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum PaletteMode {
59    #[default]
60    Commands,
61    Themes,
62}
63
64/// Responsive layout mode based on terminal width
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum LayoutMode {
67    /// >= 120 cols: full sidebar + diff
68    Full,
69    /// 90-119 cols: compact sidebar + diff
70    Compact,
71    /// 70-89 cols: overlay sidebar (toggleable)
72    Overlay,
73    /// < 70 cols: single pane mode
74    Single,
75}
76
77/// Diff view mode
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum DiffViewMode {
80    /// Traditional unified diff (default)
81    #[default]
82    Unified,
83    /// Side-by-side diff (old left, new right)
84    SideBySide,
85}
86
87#[derive(Debug, Clone)]
88pub struct EditorRequest {
89    pub file_path: String,
90    pub line: Option<u32>,
91}
92
93/// Request to open $EDITOR for writing a comment.
94#[derive(Debug, Clone)]
95pub struct CommentRequest {
96    /// Review being commented on
97    pub review_id: String,
98    /// File the comment targets
99    pub file_path: String,
100    /// Start line (new-side, 1-based)
101    pub start_line: i64,
102    /// End line (new-side, 1-based); None means single line
103    pub end_line: Option<i64>,
104    /// If Some, add comment to existing thread; if None, create new thread
105    pub thread_id: Option<String>,
106    /// Existing comments for context in the editor temp file
107    pub existing_comments: Vec<Comment>,
108}
109
110/// A comment ready to be persisted (from the inline editor).
111#[derive(Debug, Clone)]
112pub struct PendingCommentSubmission {
113    pub request: CommentRequest,
114    pub body: String,
115}
116
117/// In-TUI multi-line comment editor state.
118#[derive(Debug, Clone)]
119pub struct InlineEditor {
120    /// Lines of text (always at least one)
121    pub lines: Vec<String>,
122    /// Cursor row (0-indexed into lines)
123    pub cursor_row: usize,
124    /// Cursor column (0-indexed, character position in current line)
125    pub cursor_col: usize,
126    /// Vertical scroll offset for the text area
127    pub scroll: usize,
128    /// The comment request this editor is for
129    pub request: CommentRequest,
130}
131
132impl InlineEditor {
133    #[must_use]
134    pub fn new(request: CommentRequest) -> Self {
135        Self {
136            lines: vec![String::new()],
137            cursor_row: 0,
138            cursor_col: 0,
139            scroll: 0,
140            request,
141        }
142    }
143
144    /// Insert a character at the cursor position.
145    pub fn insert_char(&mut self, c: char) {
146        let line = &mut self.lines[self.cursor_row];
147        let byte_idx = char_to_byte_index(line, self.cursor_col);
148        line.insert(byte_idx, c);
149        self.cursor_col += 1;
150    }
151
152    /// Insert a newline, splitting the current line.
153    pub fn newline(&mut self) {
154        let line = &self.lines[self.cursor_row];
155        let byte_idx = char_to_byte_index(line, self.cursor_col);
156        let rest = self.lines[self.cursor_row][byte_idx..].to_string();
157        self.lines[self.cursor_row].truncate(byte_idx);
158        self.cursor_row += 1;
159        self.lines.insert(self.cursor_row, rest);
160        self.cursor_col = 0;
161    }
162
163    /// Delete the character before the cursor.
164    pub fn backspace(&mut self) {
165        if self.cursor_col > 0 {
166            let line = &mut self.lines[self.cursor_row];
167            let byte_idx = char_to_byte_index(line, self.cursor_col - 1);
168            let end_byte = char_to_byte_index(line, self.cursor_col);
169            line.drain(byte_idx..end_byte);
170            self.cursor_col -= 1;
171        } else if self.cursor_row > 0 {
172            // Merge with previous line
173            let current = self.lines.remove(self.cursor_row);
174            self.cursor_row -= 1;
175            self.cursor_col = self.lines[self.cursor_row].chars().count();
176            self.lines[self.cursor_row].push_str(&current);
177        }
178    }
179
180    pub fn cursor_up(&mut self) {
181        if self.cursor_row > 0 {
182            self.cursor_row -= 1;
183            self.clamp_col();
184        }
185    }
186
187    pub fn cursor_down(&mut self) {
188        if self.cursor_row + 1 < self.lines.len() {
189            self.cursor_row += 1;
190            self.clamp_col();
191        }
192    }
193
194    pub fn cursor_left(&mut self) {
195        if self.cursor_col > 0 {
196            self.cursor_col -= 1;
197        } else if self.cursor_row > 0 {
198            self.cursor_row -= 1;
199            self.cursor_col = self.lines[self.cursor_row].chars().count();
200        }
201    }
202
203    pub fn cursor_right(&mut self) {
204        let line_len = self.lines[self.cursor_row].chars().count();
205        if self.cursor_col < line_len {
206            self.cursor_col += 1;
207        } else if self.cursor_row + 1 < self.lines.len() {
208            self.cursor_row += 1;
209            self.cursor_col = 0;
210        }
211    }
212
213    pub const fn home(&mut self) {
214        self.cursor_col = 0;
215    }
216
217    pub fn end(&mut self) {
218        self.cursor_col = self.lines[self.cursor_row].chars().count();
219    }
220
221    /// Move cursor one word to the left (Alt+B).
222    pub fn word_left(&mut self) {
223        if self.cursor_col == 0 {
224            return;
225        }
226        let line = &self.lines[self.cursor_row];
227        let byte_idx = char_to_byte_index(line, self.cursor_col);
228        let before = &line[..byte_idx];
229        let trimmed = before.trim_end();
230        let word_start = trimmed
231            .rfind(|c: char| c.is_whitespace())
232            .map_or(0, |i| i + 1);
233        self.cursor_col = before[..word_start].chars().count();
234    }
235
236    /// Move cursor one word to the right (Alt+F).
237    pub fn word_right(&mut self) {
238        let line = &self.lines[self.cursor_row];
239        let line_len = line.chars().count();
240        if self.cursor_col >= line_len {
241            return;
242        }
243        let byte_idx = char_to_byte_index(line, self.cursor_col);
244        let after = &line[byte_idx..];
245        // Skip non-whitespace, then skip whitespace
246        let skip_word = after
247            .find(|c: char| c.is_whitespace())
248            .unwrap_or(after.len());
249        let rest = &after[skip_word..];
250        let skip_space = rest
251            .find(|c: char| !c.is_whitespace())
252            .unwrap_or(rest.len());
253        self.cursor_col += after[..skip_word + skip_space].chars().count();
254    }
255
256    /// Delete the word before the cursor (Ctrl+W).
257    pub fn delete_word(&mut self) {
258        if self.cursor_col == 0 {
259            return;
260        }
261        let line = &self.lines[self.cursor_row];
262        let byte_idx = char_to_byte_index(line, self.cursor_col);
263        let before = &line[..byte_idx];
264        let trimmed = before.trim_end();
265        // Find start of last word
266        let word_start = trimmed
267            .rfind(|c: char| c.is_whitespace())
268            .map_or(0, |i| i + 1);
269        let new_col = before[..word_start].chars().count();
270        let start_byte = char_to_byte_index(&self.lines[self.cursor_row], new_col);
271        self.lines[self.cursor_row].drain(start_byte..byte_idx);
272        self.cursor_col = new_col;
273    }
274
275    /// Clear from cursor to start of line (Ctrl+U).
276    pub fn clear_line(&mut self) {
277        let line = &self.lines[self.cursor_row];
278        let byte_idx = char_to_byte_index(line, self.cursor_col);
279        self.lines[self.cursor_row].drain(..byte_idx);
280        self.cursor_col = 0;
281    }
282
283    /// Get the full body text.
284    #[must_use]
285    pub fn body(&self) -> String {
286        self.lines.join("\n").trim().to_string()
287    }
288
289    /// Ensure scroll keeps cursor visible given a viewport height.
290    pub const fn ensure_visible(&mut self, viewport_height: usize) {
291        if viewport_height == 0 {
292            return;
293        }
294        if self.cursor_row < self.scroll {
295            self.scroll = self.cursor_row;
296        } else if self.cursor_row >= self.scroll + viewport_height {
297            self.scroll = self.cursor_row - viewport_height + 1;
298        }
299    }
300
301    fn clamp_col(&mut self) {
302        let line_len = self.lines[self.cursor_row].chars().count();
303        if self.cursor_col > line_len {
304            self.cursor_col = line_len;
305        }
306    }
307}
308
309/// Convert a character index to a byte index in a string.
310fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
311    s.char_indices()
312        .nth(char_idx)
313        .map_or(s.len(), |(byte_idx, _)| byte_idx)
314}
315
316impl LayoutMode {
317    /// Determine layout mode from terminal width
318    #[must_use]
319    pub const fn from_width(width: u16) -> Self {
320        match width {
321            w if w >= 130 => Self::Full,
322            w if w >= 100 => Self::Compact,
323            w if w >= 80 => Self::Overlay,
324            _ => Self::Single,
325        }
326    }
327
328    /// Get sidebar width for this layout mode
329    #[must_use]
330    pub const fn sidebar_width(self) -> u16 {
331        match self {
332            Self::Full => 34,
333            Self::Compact => 30,
334            Self::Overlay => 28,
335            Self::Single => 0,
336        }
337    }
338}
339
340/// Filter for review list
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
342pub enum ReviewFilter {
343    #[default]
344    All,
345    Open,
346    Closed,
347}
348
349/// Application state
350#[allow(clippy::struct_excessive_bools)] // TUI state inherently needs many boolean flags
351pub struct Model {
352    // === Screen state ===
353    pub screen: Screen,
354    pub focus: Focus,
355    pub previous_focus: Option<Focus>,
356
357    // === Data ===
358    pub reviews: Vec<ReviewSummary>,
359    pub current_review: Option<ReviewDetail>,
360    pub threads: Vec<ThreadSummary>,
361    pub current_thread: Option<ThreadDetail>,
362    pub all_comments: HashMap<String, Vec<Comment>>,
363    /// Parsed diff for the currently selected file
364    pub current_diff: Option<ParsedDiff>,
365    /// File content for context when no diff available
366    pub current_file_content: Option<FileContent>,
367    /// Cache for all files in the review stream
368    pub file_cache: HashMap<String, FileCacheEntry>,
369    /// Syntax highlighter
370    pub highlighter: Highlighter,
371    /// Cached highlighted lines for current diff (indexed by display line)
372    pub highlighted_lines: Vec<Vec<HighlightSpan>>,
373
374    // === UI state ===
375    /// Selected index in review list
376    pub list_index: usize,
377    /// Scroll offset in review list
378    pub list_scroll: usize,
379    /// Selected file index in sidebar
380    pub file_index: usize,
381    /// Selected index in the flat sidebar tree
382    pub sidebar_index: usize,
383    /// Scroll offset for sidebar tree
384    pub sidebar_scroll: usize,
385    /// Files whose thread children are collapsed
386    pub collapsed_files: HashSet<String>,
387    /// Scroll offset in diff pane
388    pub diff_scroll: usize,
389    /// Line cursor position in diff pane (stream row index)
390    pub diff_cursor: usize,
391    /// Currently expanded thread ID
392    pub expanded_thread: Option<String>,
393    /// Review list filter
394    pub filter: ReviewFilter,
395    /// Show sidebar in overlay mode
396    pub sidebar_visible: bool,
397    /// Diff view mode (unified or side-by-side)
398    pub diff_view_mode: DiffViewMode,
399    /// Wrap diff lines when enabled
400    pub diff_wrap: bool,
401    /// Pending editor launch request
402    pub pending_editor_request: Option<EditorRequest>,
403    /// Pending comment-via-$EDITOR request (Shift+A)
404    pub pending_comment_request: Option<CommentRequest>,
405    /// Inline comment editor state (a)
406    pub inline_editor: Option<InlineEditor>,
407    /// Comment ready for persistence (from inline editor submit)
408    pub pending_comment_submission: Option<PendingCommentSubmission>,
409
410    // === Command Palette ===
411    pub command_palette_input: String,
412    pub command_palette_selection: usize,
413    pub command_palette_commands: Vec<CommandSpec>,
414    pub command_palette_mode: PaletteMode,
415
416    // === Visual Selection ===
417    /// Whether visual line selection mode is active (Shift+V)
418    pub visual_mode: bool,
419    /// Anchor stream row where visual mode was entered
420    pub visual_anchor: usize,
421
422    // === Commenting State ===
423    pub comment_input: String,
424    pub comment_target_line: Option<u32>,
425
426    // === Layout ===
427    pub width: u16,
428    pub height: u16,
429    pub layout_mode: LayoutMode,
430
431    // === Theme ===
432    pub theme: Theme,
433    /// Theme name before opening the picker (for revert on Esc)
434    pub pre_palette_theme: Option<String>,
435    pub config: UiConfig,
436
437    // === Render-computed data ===
438    /// Thread positions captured during rendering (`thread_id` → `stream_row`)
439    pub thread_positions: RefCell<HashMap<String, usize>>,
440    /// Total stream rows from the last render pass (for cursor clamping)
441    pub max_stream_row: Cell<usize>,
442    /// Diff line mapping captured during rendering: `stream_row` → new-side line number.
443    /// Populated for every diff line (including all wrapped rows).
444    pub line_map: RefCell<HashMap<usize, i64>>,
445    /// Sorted list of stream rows that are valid cursor stops (one per logical item).
446    /// Populated during rendering; used by cursor navigation to skip wrapped/padding rows.
447    pub cursor_stops: RefCell<Vec<usize>>,
448
449    // === Review list search ===
450    pub search_input: String,
451    pub search_active: bool,
452
453    // === Repo path for display ===
454    pub repo_path: Option<String>,
455
456    // === Cached editor name for help bar ===
457    pub editor_name: String,
458
459    // === Flash message (transient error/status) ===
460    /// Shown in the help bar area until the next keypress.
461    pub flash_message: Option<String>,
462
463    // === Control ===
464    pub should_quit: bool,
465    /// Flag indicating the view needs a full redraw
466    pub needs_redraw: bool,
467
468    // === Input state ===
469    pub last_list_scroll: Option<(Instant, i8)>,
470    pub last_sidebar_scroll: Option<(Instant, i8)>,
471
472    // === Pending CLI navigation targets ===
473    pub pending_review: Option<String>,
474    pub pending_file: Option<String>,
475    pub pending_thread: Option<String>,
476}
477
478impl Model {
479    /// Create a new model
480    #[must_use]
481    pub fn new(width: u16, height: u16, config: UiConfig) -> Self {
482        Self {
483            screen: Screen::default(),
484            focus: Focus::default(),
485            previous_focus: None,
486            reviews: Vec::new(),
487            current_review: None,
488            threads: Vec::new(),
489            current_thread: None,
490            all_comments: HashMap::new(),
491            current_diff: None,
492            current_file_content: None,
493            file_cache: HashMap::new(),
494            highlighter: Highlighter::new(),
495            highlighted_lines: Vec::new(),
496            list_index: 0,
497            list_scroll: 0,
498            file_index: 0,
499            sidebar_index: 0,
500            sidebar_scroll: 0,
501            collapsed_files: HashSet::new(),
502            diff_scroll: 0,
503            diff_cursor: 0,
504            expanded_thread: None,
505            filter: ReviewFilter::default(),
506            sidebar_visible: true,
507            diff_view_mode: DiffViewMode::default(),
508            diff_wrap: true,
509            pending_editor_request: None,
510            pending_comment_request: None,
511            inline_editor: None,
512            pending_comment_submission: None,
513            command_palette_input: String::new(),
514            command_palette_selection: 0,
515            command_palette_commands: Vec::new(),
516            command_palette_mode: PaletteMode::default(),
517            visual_mode: false,
518            visual_anchor: 0,
519            comment_input: String::new(),
520            comment_target_line: None,
521            width,
522            height,
523            layout_mode: LayoutMode::from_width(width),
524            theme: Theme::default(),
525            pre_palette_theme: None,
526            config,
527            thread_positions: RefCell::new(HashMap::new()),
528            max_stream_row: Cell::new(0),
529            line_map: RefCell::new(HashMap::new()),
530            cursor_stops: RefCell::new(Vec::new()),
531            search_input: String::new(),
532            search_active: false,
533            repo_path: None,
534            editor_name: std::env::var("EDITOR")
535                .or_else(|_| std::env::var("VISUAL"))
536                .ok()
537                .and_then(|e| e.rsplit('/').next().map(String::from))
538                .unwrap_or_else(|| "Editor".to_string()),
539            flash_message: None,
540            should_quit: false,
541            needs_redraw: true,
542            last_list_scroll: None,
543            last_sidebar_scroll: None,
544            pending_review: None,
545            pending_file: None,
546            pending_thread: None,
547        }
548    }
549
550    /// Get filtered reviews based on current filter and search query
551    #[must_use]
552    pub fn filtered_reviews(&self) -> Vec<&ReviewSummary> {
553        let status_filtered: Vec<&ReviewSummary> = match self.filter {
554            ReviewFilter::All => self.reviews.iter().collect(),
555            ReviewFilter::Open => self.reviews.iter().filter(|r| r.status == "open").collect(),
556            ReviewFilter::Closed => self.reviews.iter().filter(|r| r.status != "open").collect(),
557        };
558        if self.search_input.is_empty() {
559            return status_filtered;
560        }
561        let query = self.search_input.to_lowercase();
562        status_filtered
563            .into_iter()
564            .filter(|r| {
565                r.title.to_lowercase().contains(&query)
566                    || r.review_id.to_lowercase().contains(&query)
567                    || r.author.to_lowercase().contains(&query)
568            })
569            .collect()
570    }
571
572    /// Get unique files from threads and the diff file cache for the sidebar.
573    #[must_use]
574    pub fn files_with_threads(&self) -> Vec<FileEntry> {
575        use std::collections::HashMap;
576
577        let mut files: HashMap<String, (usize, usize)> = HashMap::new();
578
579        for thread in &self.threads {
580            let entry = files.entry(thread.file_path.clone()).or_insert((0, 0));
581            if thread.status == "open" {
582                entry.0 += 1;
583            } else {
584                entry.1 += 1;
585            }
586        }
587
588        // Include cached files that have diffs but no threads.
589        for path in self.file_cache.keys() {
590            files.entry(path.clone()).or_insert((0, 0));
591        }
592
593        let mut result: Vec<_> = files
594            .into_iter()
595            .map(|(path, (open, resolved))| FileEntry {
596                path,
597                open_threads: open,
598                resolved_threads: resolved,
599            })
600            .collect();
601
602        result.sort_by(|a, b| a.path.cmp(&b.path));
603        result
604    }
605
606    /// Get threads for the currently selected file
607    #[must_use]
608    pub fn threads_for_current_file(&self) -> Vec<&ThreadSummary> {
609        let files = self.files_with_threads();
610        let Some(file) = files.get(self.file_index) else {
611            return Vec::new();
612        };
613
614        self.threads
615            .iter()
616            .filter(|t| t.file_path == file.path)
617            .collect()
618    }
619
620    /// Get threads that are visible in the current diff (all threads for the file)
621    #[must_use]
622    pub fn visible_threads_for_current_file(&self) -> Vec<&ThreadSummary> {
623        self.threads_for_current_file()
624    }
625
626    /// Build a flat list of sidebar items: files with their threads as children
627    #[must_use]
628    pub fn sidebar_items(&self) -> Vec<SidebarItem> {
629        let files = self.files_with_threads();
630        let mut items = Vec::new();
631
632        for (file_idx, file) in files.iter().enumerate() {
633            let collapsed = self.collapsed_files.contains(&file.path);
634            items.push(SidebarItem::File {
635                entry: file.clone(),
636                file_idx,
637                collapsed,
638            });
639            if !collapsed {
640                // Add threads belonging to this file, sorted by their
641                // position in the diff stream so the sidebar order matches
642                // what the user sees in the diff pane.  Fall back to
643                // selection_start for threads not yet positioned.
644                let positions = self.thread_positions.borrow();
645                let mut file_threads: Vec<&ThreadSummary> = self
646                    .threads
647                    .iter()
648                    .filter(|t| t.file_path == file.path)
649                    .collect();
650                file_threads
651                    .sort_by_key(|t| positions.get(&t.thread_id).copied().unwrap_or(usize::MAX));
652
653                for thread in file_threads {
654                    items.push(SidebarItem::Thread {
655                        thread_id: thread.thread_id.clone(),
656                        status: thread.status.clone(),
657                        comment_count: thread.comment_count,
658                        file_idx,
659                    });
660                }
661            }
662        }
663
664        items
665    }
666
667    /// Handle terminal resize
668    pub const fn resize(&mut self, width: u16, height: u16) {
669        self.width = width;
670        self.height = height;
671        self.layout_mode = LayoutMode::from_width(width);
672    }
673
674    /// Get the visible height for the review list (accounting for chrome)
675    #[must_use]
676    pub const fn list_visible_height(&self) -> usize {
677        // Account for header block (5) + search bar (2) + help bar (2)
678        // Each item is 2 lines tall
679        let available = self.height.saturating_sub(9) as usize;
680        available / 2
681    }
682
683    /// Sync current file fields from the file cache
684    pub fn sync_active_file_cache(&mut self) {
685        let files = self.files_with_threads();
686        let Some(file) = files.get(self.file_index) else {
687            self.current_diff = None;
688            self.current_file_content = None;
689            self.highlighted_lines.clear();
690            return;
691        };
692
693        if let Some(entry) = self.file_cache.get(&file.path) {
694            self.current_diff = entry.diff.clone();
695            self.current_file_content = entry.file_content.clone();
696            self.highlighted_lines = entry.highlighted_lines.clone();
697        } else {
698            self.current_diff = None;
699            self.current_file_content = None;
700            self.highlighted_lines.clear();
701        }
702    }
703}
704
705/// File entry for sidebar display
706#[derive(Debug, Clone)]
707pub struct FileEntry {
708    pub path: String,
709    pub open_threads: usize,
710    pub resolved_threads: usize,
711}
712
713/// An item in the sidebar tree (file or thread)
714#[derive(Debug, Clone)]
715pub enum SidebarItem {
716    File {
717        entry: FileEntry,
718        /// Index into `files_with_threads()` for selection matching
719        file_idx: usize,
720        /// Whether this file's threads are collapsed
721        collapsed: bool,
722    },
723    Thread {
724        thread_id: String,
725        status: String,
726        comment_count: i64,
727        /// Parent file index for selection matching
728        file_idx: usize,
729    },
730}