1use 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#[derive(Debug, Clone)]
20pub struct FileContent {
21 pub lines: Vec<String>,
22 pub start_line: i64,
24}
25
26pub struct FileCacheEntry {
28 pub diff: Option<ParsedDiff>,
29 pub file_content: Option<FileContent>,
30 pub highlighted_lines: Vec<Vec<HighlightSpan>>,
31 pub file_highlighted_lines: Vec<Vec<HighlightSpan>>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum Screen {
39 #[default]
40 ReviewList,
41 ReviewDetail,
42}
43
44#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum PaletteMode {
59 #[default]
60 Commands,
61 Themes,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum LayoutMode {
67 Full,
69 Compact,
71 Overlay,
73 Single,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum DiffViewMode {
80 #[default]
82 Unified,
83 SideBySide,
85}
86
87#[derive(Debug, Clone)]
88pub struct EditorRequest {
89 pub file_path: String,
90 pub line: Option<u32>,
91}
92
93#[derive(Debug, Clone)]
95pub struct CommentRequest {
96 pub review_id: String,
98 pub file_path: String,
100 pub start_line: i64,
102 pub end_line: Option<i64>,
104 pub thread_id: Option<String>,
106 pub existing_comments: Vec<Comment>,
108}
109
110#[derive(Debug, Clone)]
112pub struct PendingCommentSubmission {
113 pub request: CommentRequest,
114 pub body: String,
115}
116
117#[derive(Debug, Clone)]
119pub struct InlineEditor {
120 pub lines: Vec<String>,
122 pub cursor_row: usize,
124 pub cursor_col: usize,
126 pub scroll: usize,
128 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 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 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 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 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(¤t);
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 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 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 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 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 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 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 #[must_use]
285 pub fn body(&self) -> String {
286 self.lines.join("\n").trim().to_string()
287 }
288
289 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
309fn 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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
342pub enum ReviewFilter {
343 #[default]
344 All,
345 Open,
346 Closed,
347}
348
349#[allow(clippy::struct_excessive_bools)] pub struct Model {
352 pub screen: Screen,
354 pub focus: Focus,
355 pub previous_focus: Option<Focus>,
356
357 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 pub current_diff: Option<ParsedDiff>,
365 pub current_file_content: Option<FileContent>,
367 pub file_cache: HashMap<String, FileCacheEntry>,
369 pub highlighter: Highlighter,
371 pub highlighted_lines: Vec<Vec<HighlightSpan>>,
373
374 pub list_index: usize,
377 pub list_scroll: usize,
379 pub file_index: usize,
381 pub sidebar_index: usize,
383 pub sidebar_scroll: usize,
385 pub collapsed_files: HashSet<String>,
387 pub diff_scroll: usize,
389 pub diff_cursor: usize,
391 pub expanded_thread: Option<String>,
393 pub filter: ReviewFilter,
395 pub sidebar_visible: bool,
397 pub diff_view_mode: DiffViewMode,
399 pub diff_wrap: bool,
401 pub pending_editor_request: Option<EditorRequest>,
403 pub pending_comment_request: Option<CommentRequest>,
405 pub inline_editor: Option<InlineEditor>,
407 pub pending_comment_submission: Option<PendingCommentSubmission>,
409
410 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 pub visual_mode: bool,
419 pub visual_anchor: usize,
421
422 pub comment_input: String,
424 pub comment_target_line: Option<u32>,
425
426 pub width: u16,
428 pub height: u16,
429 pub layout_mode: LayoutMode,
430
431 pub theme: Theme,
433 pub pre_palette_theme: Option<String>,
435 pub config: UiConfig,
436
437 pub thread_positions: RefCell<HashMap<String, usize>>,
440 pub max_stream_row: Cell<usize>,
442 pub line_map: RefCell<HashMap<usize, i64>>,
445 pub cursor_stops: RefCell<Vec<usize>>,
448
449 pub search_input: String,
451 pub search_active: bool,
452
453 pub repo_path: Option<String>,
455
456 pub editor_name: String,
458
459 pub flash_message: Option<String>,
462
463 pub should_quit: bool,
465 pub needs_redraw: bool,
467
468 pub last_list_scroll: Option<(Instant, i8)>,
470 pub last_sidebar_scroll: Option<(Instant, i8)>,
471
472 pub pending_review: Option<String>,
474 pub pending_file: Option<String>,
475 pub pending_thread: Option<String>,
476}
477
478impl Model {
479 #[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 #[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 #[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 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 #[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 #[must_use]
622 pub fn visible_threads_for_current_file(&self) -> Vec<&ThreadSummary> {
623 self.threads_for_current_file()
624 }
625
626 #[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 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 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 #[must_use]
676 pub const fn list_visible_height(&self) -> usize {
677 let available = self.height.saturating_sub(9) as usize;
680 available / 2
681 }
682
683 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#[derive(Debug, Clone)]
707pub struct FileEntry {
708 pub path: String,
709 pub open_threads: usize,
710 pub resolved_threads: usize,
711}
712
713#[derive(Debug, Clone)]
715pub enum SidebarItem {
716 File {
717 entry: FileEntry,
718 file_idx: usize,
720 collapsed: bool,
722 },
723 Thread {
724 thread_id: String,
725 status: String,
726 comment_count: i64,
727 file_idx: usize,
729 },
730}