use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::time::Instant;
use crate::command::CommandSpec;
use crate::config::UiConfig;
use crate::db::{Comment, ReviewDetail, ReviewSummary, ThreadDetail, ThreadSummary};
use crate::diff::ParsedDiff;
use crate::syntax::{HighlightSpan, Highlighter};
use crate::theme::Theme;
#[derive(Debug, Clone)]
pub struct FileContent {
pub lines: Vec<String>,
pub start_line: i64,
}
pub struct FileCacheEntry {
pub diff: Option<ParsedDiff>,
pub file_content: Option<FileContent>,
pub highlighted_lines: Vec<Vec<HighlightSpan>>,
pub file_highlighted_lines: Vec<Vec<HighlightSpan>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Screen {
#[default]
ReviewList,
ReviewDetail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Focus {
#[default]
ReviewList,
FileSidebar,
DiffPane,
ThreadExpanded,
CommandPalette,
Commenting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PaletteMode {
#[default]
Commands,
Themes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutMode {
Full,
Compact,
Overlay,
Single,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiffViewMode {
#[default]
Unified,
SideBySide,
}
#[derive(Debug, Clone)]
pub struct EditorRequest {
pub file_path: String,
pub line: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct CommentRequest {
pub review_id: String,
pub file_path: String,
pub start_line: i64,
pub end_line: Option<i64>,
pub thread_id: Option<String>,
pub existing_comments: Vec<Comment>,
}
#[derive(Debug, Clone)]
pub struct PendingCommentSubmission {
pub request: CommentRequest,
pub body: String,
}
#[derive(Debug, Clone)]
pub struct InlineEditor {
pub lines: Vec<String>,
pub cursor_row: usize,
pub cursor_col: usize,
pub scroll: usize,
pub request: CommentRequest,
}
impl InlineEditor {
#[must_use]
pub fn new(request: CommentRequest) -> Self {
Self {
lines: vec![String::new()],
cursor_row: 0,
cursor_col: 0,
scroll: 0,
request,
}
}
pub fn insert_char(&mut self, c: char) {
let line = &mut self.lines[self.cursor_row];
let byte_idx = char_to_byte_index(line, self.cursor_col);
line.insert(byte_idx, c);
self.cursor_col += 1;
}
pub fn newline(&mut self) {
let line = &self.lines[self.cursor_row];
let byte_idx = char_to_byte_index(line, self.cursor_col);
let rest = self.lines[self.cursor_row][byte_idx..].to_string();
self.lines[self.cursor_row].truncate(byte_idx);
self.cursor_row += 1;
self.lines.insert(self.cursor_row, rest);
self.cursor_col = 0;
}
pub fn backspace(&mut self) {
if self.cursor_col > 0 {
let line = &mut self.lines[self.cursor_row];
let byte_idx = char_to_byte_index(line, self.cursor_col - 1);
let end_byte = char_to_byte_index(line, self.cursor_col);
line.drain(byte_idx..end_byte);
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
let current = self.lines.remove(self.cursor_row);
self.cursor_row -= 1;
self.cursor_col = self.lines[self.cursor_row].chars().count();
self.lines[self.cursor_row].push_str(¤t);
}
}
pub fn cursor_up(&mut self) {
if self.cursor_row > 0 {
self.cursor_row -= 1;
self.clamp_col();
}
}
pub fn cursor_down(&mut self) {
if self.cursor_row + 1 < self.lines.len() {
self.cursor_row += 1;
self.clamp_col();
}
}
pub fn cursor_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = self.lines[self.cursor_row].chars().count();
}
}
pub fn cursor_right(&mut self) {
let line_len = self.lines[self.cursor_row].chars().count();
if self.cursor_col < line_len {
self.cursor_col += 1;
} else if self.cursor_row + 1 < self.lines.len() {
self.cursor_row += 1;
self.cursor_col = 0;
}
}
pub const fn home(&mut self) {
self.cursor_col = 0;
}
pub fn end(&mut self) {
self.cursor_col = self.lines[self.cursor_row].chars().count();
}
pub fn word_left(&mut self) {
if self.cursor_col == 0 {
return;
}
let line = &self.lines[self.cursor_row];
let byte_idx = char_to_byte_index(line, self.cursor_col);
let before = &line[..byte_idx];
let trimmed = before.trim_end();
let word_start = trimmed
.rfind(|c: char| c.is_whitespace())
.map_or(0, |i| i + 1);
self.cursor_col = before[..word_start].chars().count();
}
pub fn word_right(&mut self) {
let line = &self.lines[self.cursor_row];
let line_len = line.chars().count();
if self.cursor_col >= line_len {
return;
}
let byte_idx = char_to_byte_index(line, self.cursor_col);
let after = &line[byte_idx..];
let skip_word = after
.find(|c: char| c.is_whitespace())
.unwrap_or(after.len());
let rest = &after[skip_word..];
let skip_space = rest
.find(|c: char| !c.is_whitespace())
.unwrap_or(rest.len());
self.cursor_col += after[..skip_word + skip_space].chars().count();
}
pub fn delete_word(&mut self) {
if self.cursor_col == 0 {
return;
}
let line = &self.lines[self.cursor_row];
let byte_idx = char_to_byte_index(line, self.cursor_col);
let before = &line[..byte_idx];
let trimmed = before.trim_end();
let word_start = trimmed
.rfind(|c: char| c.is_whitespace())
.map_or(0, |i| i + 1);
let new_col = before[..word_start].chars().count();
let start_byte = char_to_byte_index(&self.lines[self.cursor_row], new_col);
self.lines[self.cursor_row].drain(start_byte..byte_idx);
self.cursor_col = new_col;
}
pub fn clear_line(&mut self) {
let line = &self.lines[self.cursor_row];
let byte_idx = char_to_byte_index(line, self.cursor_col);
self.lines[self.cursor_row].drain(..byte_idx);
self.cursor_col = 0;
}
#[must_use]
pub fn body(&self) -> String {
self.lines.join("\n").trim().to_string()
}
pub const fn ensure_visible(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
if self.cursor_row < self.scroll {
self.scroll = self.cursor_row;
} else if self.cursor_row >= self.scroll + viewport_height {
self.scroll = self.cursor_row - viewport_height + 1;
}
}
fn clamp_col(&mut self) {
let line_len = self.lines[self.cursor_row].chars().count();
if self.cursor_col > line_len {
self.cursor_col = line_len;
}
}
}
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map_or(s.len(), |(byte_idx, _)| byte_idx)
}
impl LayoutMode {
#[must_use]
pub const fn from_width(width: u16) -> Self {
match width {
w if w >= 130 => Self::Full,
w if w >= 100 => Self::Compact,
w if w >= 80 => Self::Overlay,
_ => Self::Single,
}
}
#[must_use]
pub const fn sidebar_width(self) -> u16 {
match self {
Self::Full => 34,
Self::Compact => 30,
Self::Overlay => 28,
Self::Single => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReviewFilter {
#[default]
All,
Open,
Closed,
}
#[allow(clippy::struct_excessive_bools)] pub struct Model {
pub screen: Screen,
pub focus: Focus,
pub previous_focus: Option<Focus>,
pub reviews: Vec<ReviewSummary>,
pub current_review: Option<ReviewDetail>,
pub threads: Vec<ThreadSummary>,
pub current_thread: Option<ThreadDetail>,
pub all_comments: HashMap<String, Vec<Comment>>,
pub current_diff: Option<ParsedDiff>,
pub current_file_content: Option<FileContent>,
pub file_cache: HashMap<String, FileCacheEntry>,
pub highlighter: Highlighter,
pub highlighted_lines: Vec<Vec<HighlightSpan>>,
pub list_index: usize,
pub list_scroll: usize,
pub file_index: usize,
pub sidebar_index: usize,
pub sidebar_scroll: usize,
pub collapsed_files: HashSet<String>,
pub diff_scroll: usize,
pub diff_cursor: usize,
pub expanded_thread: Option<String>,
pub filter: ReviewFilter,
pub sidebar_visible: bool,
pub diff_view_mode: DiffViewMode,
pub diff_wrap: bool,
pub pending_editor_request: Option<EditorRequest>,
pub pending_comment_request: Option<CommentRequest>,
pub inline_editor: Option<InlineEditor>,
pub pending_comment_submission: Option<PendingCommentSubmission>,
pub command_palette_input: String,
pub command_palette_selection: usize,
pub command_palette_commands: Vec<CommandSpec>,
pub command_palette_mode: PaletteMode,
pub visual_mode: bool,
pub visual_anchor: usize,
pub comment_input: String,
pub comment_target_line: Option<u32>,
pub width: u16,
pub height: u16,
pub layout_mode: LayoutMode,
pub theme: Theme,
pub pre_palette_theme: Option<String>,
pub config: UiConfig,
pub thread_positions: RefCell<HashMap<String, usize>>,
pub max_stream_row: Cell<usize>,
pub line_map: RefCell<HashMap<usize, i64>>,
pub cursor_stops: RefCell<Vec<usize>>,
pub search_input: String,
pub search_active: bool,
pub repo_path: Option<String>,
pub editor_name: String,
pub flash_message: Option<String>,
pub should_quit: bool,
pub needs_redraw: bool,
pub last_list_scroll: Option<(Instant, i8)>,
pub last_sidebar_scroll: Option<(Instant, i8)>,
pub pending_review: Option<String>,
pub pending_file: Option<String>,
pub pending_thread: Option<String>,
}
impl Model {
#[must_use]
pub fn new(width: u16, height: u16, config: UiConfig) -> Self {
Self {
screen: Screen::default(),
focus: Focus::default(),
previous_focus: None,
reviews: Vec::new(),
current_review: None,
threads: Vec::new(),
current_thread: None,
all_comments: HashMap::new(),
current_diff: None,
current_file_content: None,
file_cache: HashMap::new(),
highlighter: Highlighter::new(),
highlighted_lines: Vec::new(),
list_index: 0,
list_scroll: 0,
file_index: 0,
sidebar_index: 0,
sidebar_scroll: 0,
collapsed_files: HashSet::new(),
diff_scroll: 0,
diff_cursor: 0,
expanded_thread: None,
filter: ReviewFilter::default(),
sidebar_visible: true,
diff_view_mode: DiffViewMode::default(),
diff_wrap: true,
pending_editor_request: None,
pending_comment_request: None,
inline_editor: None,
pending_comment_submission: None,
command_palette_input: String::new(),
command_palette_selection: 0,
command_palette_commands: Vec::new(),
command_palette_mode: PaletteMode::default(),
visual_mode: false,
visual_anchor: 0,
comment_input: String::new(),
comment_target_line: None,
width,
height,
layout_mode: LayoutMode::from_width(width),
theme: Theme::default(),
pre_palette_theme: None,
config,
thread_positions: RefCell::new(HashMap::new()),
max_stream_row: Cell::new(0),
line_map: RefCell::new(HashMap::new()),
cursor_stops: RefCell::new(Vec::new()),
search_input: String::new(),
search_active: false,
repo_path: None,
editor_name: std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.ok()
.and_then(|e| e.rsplit('/').next().map(String::from))
.unwrap_or_else(|| "Editor".to_string()),
flash_message: None,
should_quit: false,
needs_redraw: true,
last_list_scroll: None,
last_sidebar_scroll: None,
pending_review: None,
pending_file: None,
pending_thread: None,
}
}
#[must_use]
pub fn filtered_reviews(&self) -> Vec<&ReviewSummary> {
let status_filtered: Vec<&ReviewSummary> = match self.filter {
ReviewFilter::All => self.reviews.iter().collect(),
ReviewFilter::Open => self.reviews.iter().filter(|r| r.status == "open").collect(),
ReviewFilter::Closed => self.reviews.iter().filter(|r| r.status != "open").collect(),
};
if self.search_input.is_empty() {
return status_filtered;
}
let query = self.search_input.to_lowercase();
status_filtered
.into_iter()
.filter(|r| {
r.title.to_lowercase().contains(&query)
|| r.review_id.to_lowercase().contains(&query)
|| r.author.to_lowercase().contains(&query)
})
.collect()
}
#[must_use]
pub fn files_with_threads(&self) -> Vec<FileEntry> {
use std::collections::HashMap;
let mut files: HashMap<String, (usize, usize)> = HashMap::new();
for thread in &self.threads {
let entry = files.entry(thread.file_path.clone()).or_insert((0, 0));
if thread.status == "open" {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
for path in self.file_cache.keys() {
files.entry(path.clone()).or_insert((0, 0));
}
let mut result: Vec<_> = files
.into_iter()
.map(|(path, (open, resolved))| FileEntry {
path,
open_threads: open,
resolved_threads: resolved,
})
.collect();
result.sort_by(|a, b| a.path.cmp(&b.path));
result
}
#[must_use]
pub fn threads_for_current_file(&self) -> Vec<&ThreadSummary> {
let files = self.files_with_threads();
let Some(file) = files.get(self.file_index) else {
return Vec::new();
};
self.threads
.iter()
.filter(|t| t.file_path == file.path)
.collect()
}
#[must_use]
pub fn visible_threads_for_current_file(&self) -> Vec<&ThreadSummary> {
self.threads_for_current_file()
}
#[must_use]
pub fn sidebar_items(&self) -> Vec<SidebarItem> {
let files = self.files_with_threads();
let mut items = Vec::new();
for (file_idx, file) in files.iter().enumerate() {
let collapsed = self.collapsed_files.contains(&file.path);
items.push(SidebarItem::File {
entry: file.clone(),
file_idx,
collapsed,
});
if !collapsed {
let positions = self.thread_positions.borrow();
let mut file_threads: Vec<&ThreadSummary> = self
.threads
.iter()
.filter(|t| t.file_path == file.path)
.collect();
file_threads
.sort_by_key(|t| positions.get(&t.thread_id).copied().unwrap_or(usize::MAX));
for thread in file_threads {
items.push(SidebarItem::Thread {
thread_id: thread.thread_id.clone(),
status: thread.status.clone(),
comment_count: thread.comment_count,
file_idx,
});
}
}
}
items
}
pub const fn resize(&mut self, width: u16, height: u16) {
self.width = width;
self.height = height;
self.layout_mode = LayoutMode::from_width(width);
}
#[must_use]
pub const fn list_visible_height(&self) -> usize {
let available = self.height.saturating_sub(9) as usize;
available / 2
}
pub fn sync_active_file_cache(&mut self) {
let files = self.files_with_threads();
let Some(file) = files.get(self.file_index) else {
self.current_diff = None;
self.current_file_content = None;
self.highlighted_lines.clear();
return;
};
if let Some(entry) = self.file_cache.get(&file.path) {
self.current_diff = entry.diff.clone();
self.current_file_content = entry.file_content.clone();
self.highlighted_lines = entry.highlighted_lines.clone();
} else {
self.current_diff = None;
self.current_file_content = None;
self.highlighted_lines.clear();
}
}
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub path: String,
pub open_threads: usize,
pub resolved_threads: usize,
}
#[derive(Debug, Clone)]
pub enum SidebarItem {
File {
entry: FileEntry,
file_idx: usize,
collapsed: bool,
},
Thread {
thread_id: String,
status: String,
comment_count: i64,
file_idx: usize,
},
}