use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::comments::Comments;
use crate::tree::{Row, RowKind};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommentScope {
Worktree,
Commit(String),
}
#[derive(Clone, Debug)]
pub struct FileHistory {
pub file: PathBuf,
pub commits: Vec<crate::git::CommitInfo>,
pub idx: usize,
pub baseline_scope: CommentScope,
}
#[derive(Clone, Debug)]
pub struct SearchState {
pub query: String,
pub matches: Vec<usize>,
pub cur: usize,
}
const MAX_FILE_HISTORY: usize = 50;
pub const MAX_CONTEXT_LINES: u32 = 50;
const MAX_HSCROLL: usize = 500;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Section {
Unstaged,
Staged,
Commit,
All,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Unstaged,
Staged,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pane {
Files,
Diff,
Comments,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RightTab {
#[default]
Comments,
Debug,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommentRow {
Header(crate::comments::CommentStatus, usize),
Item(usize),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewMode {
Changes,
Commits,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionState {
Starting,
Running,
Stopped,
Exited,
}
fn push_var_rows(
vars: &[crate::dap::VarRow],
frame: usize,
prefix: &mut Vec<usize>,
out: &mut Vec<DebugRow>,
) {
for (i, v) in vars.iter().enumerate() {
prefix.push(i);
out.push(DebugRow::Var(frame, prefix.clone()));
if v.expanded {
push_var_rows(&v.children, frame, prefix, out);
}
prefix.pop();
}
}
pub fn var_at_path<'a>(
vars: &'a [crate::dap::VarRow],
path: &[usize],
) -> Option<&'a crate::dap::VarRow> {
let (first, rest) = path.split_first()?;
let v = vars.get(*first)?;
if rest.is_empty() {
Some(v)
} else {
var_at_path(&v.children, rest)
}
}
fn var_at_path_mut<'a>(
vars: &'a mut [crate::dap::VarRow],
path: &[usize],
) -> Option<&'a mut crate::dap::VarRow> {
let (first, rest) = path.split_first()?;
let v = vars.get_mut(*first)?;
if rest.is_empty() {
Some(v)
} else {
var_at_path_mut(&mut v.children, rest)
}
}
#[derive(Clone, Debug)]
pub struct DebugSession {
pub id: u64,
pub label: String,
pub state: SessionState,
pub stopped_thread: Option<i64>,
pub stopped_at: Option<(PathBuf, u32)>,
pub stack: Vec<crate::dap::Frame>,
pub frame_sel: usize,
pub locals: Vec<crate::dap::VarRow>,
}
impl DebugSession {
pub fn new(id: u64, label: String) -> Self {
DebugSession {
id,
label,
state: SessionState::Starting,
stopped_thread: None,
stopped_at: None,
stack: Vec::new(),
frame_sel: 0,
locals: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum DebugTab {
#[default]
Vars,
Breakpoints,
}
#[derive(Clone, Debug, Default)]
pub struct DebugState {
pub sessions: Vec<DebugSession>,
pub active: usize,
pub tab: DebugTab,
pub breakpoints: std::collections::BTreeMap<PathBuf, std::collections::BTreeMap<u32, bool>>,
pub panel_sel: usize,
pub bp_sel: usize,
pub hscroll: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DebugRow {
Frame(usize),
Var(usize, Vec<usize>),
}
impl DebugState {
pub fn active_session(&self) -> Option<&DebugSession> {
self.sessions.get(self.active)
}
pub fn debug_rows(&self) -> Vec<DebugRow> {
let mut rows = Vec::new();
let Some(sess) = self.active_session() else {
return rows;
};
for (fi, frame) in sess.stack.iter().enumerate() {
rows.push(DebugRow::Frame(fi));
push_var_rows(&frame.locals, fi, &mut Vec::new(), &mut rows);
}
rows
}
pub fn breakpoint_list(&self) -> Vec<(PathBuf, u32, bool)> {
self.breakpoints
.iter()
.flat_map(|(f, lines)| lines.iter().map(move |(l, on)| (f.clone(), *l, *on)))
.collect()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Added,
Modified,
Deleted,
Renamed,
Other,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineKind {
Hunk,
Add,
Del,
Context,
}
#[derive(Clone, Debug)]
pub struct FileChange {
pub path: PathBuf,
pub status: Status,
}
#[derive(Clone, Debug)]
pub struct DiffLine {
pub kind: LineKind,
pub text: String,
pub old_lineno: Option<u32>,
pub new_lineno: Option<u32>,
}
impl DiffLine {
pub fn context(text: &str, old: u32, new: u32) -> Self {
DiffLine {
kind: LineKind::Context,
text: text.into(),
old_lineno: Some(old),
new_lineno: Some(new),
}
}
}
pub struct InputState {
pub buffer: String, pub target_file: PathBuf,
pub target_line: u32,
pub target_hunk: String,
pub anchor_line_text: String,
pub anchor_before: Vec<String>,
pub anchor_after: Vec<String>,
pub debug_snapshot: Option<crate::dap::DebugSnapshot>,
pub attach_debug: bool,
}
pub struct CommittedComment {
pub file: PathBuf,
pub line: u32,
pub hunk: String,
pub text: String,
pub line_text: String,
pub context_before: Vec<String>,
pub context_after: Vec<String>,
pub debug_snapshot: Option<crate::dap::DebugSnapshot>,
}
pub struct App {
pub repo_root: PathBuf,
pub focus: Pane,
pub view: ViewMode,
pub unstaged: Vec<FileChange>,
pub staged: Vec<FileChange>,
pub all_files: Vec<FileChange>,
pub show_all_files: bool,
pub selected: usize,
pub diff: Vec<DiffLine>,
pub diff_cursor: usize,
pub diff_hscroll: usize,
pub select_anchor: Option<usize>,
pub reviewed: HashSet<PathBuf>,
pub status_msg: Option<String>,
pub collapsed: HashSet<(Section, PathBuf)>,
pub rows: Vec<Row>,
pub hide_reviewed: bool,
pub context_lines: u32,
pub full_file: bool,
pub show_files: bool,
pub file_pane_pct: u16,
pub right_pane_pct: u16,
pub comments: Comments,
pub input: Option<InputState>,
pub commits: Vec<crate::git::CommitInfo>,
pub selected_commit: usize,
pub commit_limit: usize,
pub commit_stats: std::collections::HashMap<String, crate::git::CommitStat>,
pub open_commit: Option<String>,
pub commit_files: Vec<FileChange>,
pub show_help: bool,
pub comment_scope: CommentScope,
pub show_comments: bool,
pub comment_selected: usize,
pub theme: crate::theme::Theme,
pub split_diff: bool,
pub history: Option<FileHistory>,
pub search: Option<SearchState>,
pub search_input: Option<String>,
pub debug: Option<DebugState>,
pub right_tab: RightTab,
pub coverage: Option<crate::coverage::Coverage>,
pub show_coverage: bool,
pub debug_launch_pick: Option<usize>,
pub proc_picker: Option<ProcPicker>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LaunchMode {
Worktree,
Commit,
Remote,
Process,
}
#[derive(Clone, Debug, Default)]
pub struct ProcPicker {
pub procs: Vec<crate::process::ProcInfo>,
pub filter: String,
pub sel: usize,
}
impl ProcPicker {
pub fn filtered(&self) -> Vec<&crate::process::ProcInfo> {
if self.filter.is_empty() {
return self.procs.iter().collect();
}
let f = self.filter.to_lowercase();
self.procs
.iter()
.filter(|p| {
p.command.to_lowercase().contains(&f) || p.pid.to_string().contains(&f)
})
.collect()
}
}
enum RowId {
File(Section, std::path::PathBuf),
Dir(Section, std::path::PathBuf),
}
impl App {
pub fn new(unstaged: Vec<FileChange>, staged: Vec<FileChange>, repo_root: PathBuf) -> Self {
let mut app = App {
repo_root,
focus: Pane::Files,
view: ViewMode::Changes,
unstaged,
staged,
all_files: Vec::new(),
show_all_files: false,
selected: 0,
diff: Vec::new(),
diff_cursor: 0,
diff_hscroll: 0,
select_anchor: None,
reviewed: HashSet::new(),
status_msg: None,
collapsed: HashSet::new(),
rows: Vec::new(),
hide_reviewed: false,
context_lines: 3,
full_file: false,
show_files: true,
file_pane_pct: 25,
right_pane_pct: 30,
comments: Comments::default(),
input: None,
commits: Vec::new(),
selected_commit: 0,
commit_limit: crate::COMMIT_PAGE,
commit_stats: std::collections::HashMap::new(),
open_commit: None,
commit_files: Vec::new(),
show_help: false,
comment_scope: CommentScope::Worktree,
show_comments: false,
comment_selected: 0,
theme: crate::theme::Theme::Dark,
split_diff: false,
history: None,
search: None,
search_input: None,
debug: None,
right_tab: RightTab::Comments,
coverage: None,
show_coverage: false,
debug_launch_pick: None,
proc_picker: None,
};
app.rebuild_rows();
app
}
pub fn toggle_full_file(&mut self) {
self.full_file = !self.full_file;
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn toggle_theme(&mut self) {
self.theme = match self.theme {
crate::theme::Theme::Dark => crate::theme::Theme::Light,
crate::theme::Theme::Light => crate::theme::Theme::Dark,
};
}
pub fn toggle_split(&mut self) {
self.split_diff = !self.split_diff;
}
pub fn palette(&self) -> crate::theme::Palette {
crate::theme::Palette::for_theme(self.theme)
}
pub fn toggle_files(&mut self) {
self.show_files = !self.show_files;
if !self.show_files && self.focus == Pane::Files {
self.focus = Pane::Diff;
}
}
pub fn widen_files(&mut self) {
self.file_pane_pct = (self.file_pane_pct + 5).min(60);
}
pub fn narrow_files(&mut self) {
self.file_pane_pct = self.file_pane_pct.saturating_sub(5).max(10);
}
pub fn widen_right(&mut self) {
self.right_pane_pct = (self.right_pane_pct + 5).min(60);
}
pub fn narrow_right(&mut self) {
self.right_pane_pct = self.right_pane_pct.saturating_sub(5).max(15);
}
pub fn resize_focused_pane(&mut self, bigger: bool) {
if self.focus == Pane::Comments {
if bigger {
self.narrow_right();
} else {
self.widen_right();
}
} else if bigger {
self.widen_files();
} else {
self.narrow_files();
}
}
pub fn effective_context(&self) -> u32 {
if self.full_file {
u32::MAX
} else {
self.context_lines
}
}
pub fn rebuild_rows(&mut self) {
let prev = self.selected_identity();
let empty = HashSet::new();
let hidden = if self.hide_reviewed {
&self.reviewed
} else {
&empty
};
self.rows = if self.view == ViewMode::Commits && self.open_commit.is_some() {
crate::tree::build_commit_rows(&self.commit_files, &self.collapsed, hidden)
} else if self.show_all_files {
crate::tree::build_all_rows(&self.all_files, &self.collapsed)
} else {
crate::tree::build_rows(&self.unstaged, &self.staged, &self.collapsed, hidden)
};
self.selected = match prev.and_then(|id| self.find_row(&id)) {
Some(i) => i,
None => self.selected.min(self.rows.len().saturating_sub(1)),
};
}
fn selected_identity(&self) -> Option<RowId> {
let row = self.rows.get(self.selected)?;
match &row.kind {
RowKind::File {
section,
file_index,
} => {
return self
.section_files(*section)
.get(*file_index)
.map(|f| RowId::File(*section, f.path.clone()));
}
RowKind::Dir { section, path, .. } => Some(RowId::Dir(*section, path.clone())),
RowKind::Header { .. } => None,
}
}
fn find_row(&self, id: &RowId) -> Option<usize> {
self.rows.iter().position(|r| match (&r.kind, id) {
(
RowKind::File {
section: rs,
file_index,
},
RowId::File(s, p),
) if rs == s => self
.section_files(*rs)
.get(*file_index)
.map_or(false, |f| &f.path == p),
(
RowKind::Dir {
section: rs, path, ..
},
RowId::Dir(s, p),
) if rs == s => path == p,
_ => false,
})
}
pub fn selected_is_all_file(&self) -> bool {
self.show_all_files
&& matches!(
self.rows.get(self.selected).map(|r| &r.kind),
Some(RowKind::File { section: Section::All, .. })
)
}
pub fn section_files(&self, section: Section) -> &[FileChange] {
match section {
Section::Unstaged => &self.unstaged,
Section::Staged => &self.staged,
Section::Commit => &self.commit_files,
Section::All => &self.all_files,
}
}
pub fn toggle_hide_reviewed(&mut self) {
self.hide_reviewed = !self.hide_reviewed;
self.rebuild_rows();
}
pub fn selected_path(&self) -> Option<&PathBuf> {
match self.rows.get(self.selected) {
Some(Row {
kind:
RowKind::File {
section,
file_index,
},
..
}) => self
.section_files(*section)
.get(*file_index)
.map(|f| &f.path),
_ => None,
}
}
pub fn selected_section(&self) -> Option<Section> {
match self.rows.get(self.selected) {
Some(Row {
kind: RowKind::File { section, .. },
..
}) => Some(*section),
_ => None,
}
}
pub fn selected_file_index(&self) -> Option<usize> {
match self.rows.get(self.selected) {
Some(Row {
kind: RowKind::File { file_index, .. },
..
}) => Some(*file_index),
_ => None,
}
}
pub fn set_diff(&mut self, diff: Vec<DiffLine>) {
self.diff = diff;
self.diff_cursor = 0;
self.diff_hscroll = 0;
self.search = None;
self.search_input = None;
}
pub fn move_selection(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let max = self.rows.len() as isize - 1;
let next = (self.selected as isize + delta).clamp(0, max);
self.selected = next as usize;
}
pub fn scroll_h(&mut self, delta: isize) {
let next = (self.diff_hscroll as isize + delta).max(0);
self.diff_hscroll = (next as usize).min(MAX_HSCROLL);
}
pub fn move_diff_cursor(&mut self, delta: isize) {
if self.diff.is_empty() {
self.diff_cursor = 0;
return;
}
let max = self.diff.len() as isize - 1;
let mut pos = (self.diff_cursor as isize + delta).clamp(0, max);
let skip = |k: LineKind| -> bool {
k == LineKind::Hunk || (self.split_diff && k == LineKind::Del)
};
let step = if delta >= 0 { 1 } else { -1 };
while skip(self.diff[pos as usize].kind) {
let next = pos + step;
if next < 0 || next > max {
let mut back = pos - step;
while (0..=max).contains(&back) && skip(self.diff[back as usize].kind) {
back -= step;
}
if (0..=max).contains(&back) {
pos = back;
}
break;
}
pos = next;
}
self.diff_cursor = pos as usize;
}
pub fn select_active(&self) -> bool {
self.select_anchor.is_some()
}
pub fn start_select(&mut self) {
self.select_anchor = Some(self.diff_cursor);
}
pub fn cancel_select(&mut self) {
self.select_anchor = None;
}
pub fn select_range(&self) -> (usize, usize) {
match self.select_anchor {
Some(a) => (a.min(self.diff_cursor), a.max(self.diff_cursor)),
None => (self.diff_cursor, self.diff_cursor),
}
}
pub fn selection_text(&self) -> String {
let (lo, hi) = self.select_range();
let mut out = String::new();
for dl in &self.diff[lo..=hi] {
if dl.kind == LineKind::Hunk {
continue;
}
out.push_str(&dl.text);
out.push('\n');
}
out
}
pub fn to_top(&mut self) {
match self.focus {
Pane::Files => self.selected = 0,
Pane::Diff => self.diff_cursor = 0,
Pane::Comments => match self.right_tab {
RightTab::Comments => self.comment_selected = 0,
RightTab::Debug => {
if let Some(d) = self.debug.as_mut() {
d.panel_sel = 0;
}
}
},
}
}
pub fn to_bottom(&mut self) {
match self.focus {
Pane::Files => self.selected = self.rows.len().saturating_sub(1),
Pane::Diff => self.diff_cursor = self.diff.len().saturating_sub(1),
Pane::Comments => match self.right_tab {
RightTab::Comments => {
let len = self.comment_rows().len();
self.comment_selected = len.saturating_sub(1);
}
RightTab::Debug => {
let len = self.debug_panel_len();
if let Some(d) = self.debug.as_mut() {
d.panel_sel = len.saturating_sub(1);
}
}
},
}
}
pub fn right_pane_visible(&self) -> bool {
self.show_comments || self.debug_active()
}
pub fn toggle_focus(&mut self) {
let panes: Vec<Pane> = [Pane::Files, Pane::Diff, Pane::Comments]
.iter()
.copied()
.filter(|&p| match p {
Pane::Files => self.show_files,
Pane::Diff => true,
Pane::Comments => self.right_pane_visible(),
})
.collect();
if panes.len() <= 1 {
return; }
let current = panes.iter().position(|&p| p == self.focus).unwrap_or(0);
self.focus = panes[(current + 1) % panes.len()];
}
pub fn debug_scroll_h(&mut self, delta: isize) {
if let Some(d) = self.debug.as_mut() {
d.hscroll = (d.hscroll as isize + delta).max(0) as usize;
}
}
pub fn is_debug_focused(&self) -> bool {
self.focus == Pane::Comments && self.right_tab == RightTab::Debug
}
pub fn toggle_right_tab(&mut self) {
self.right_tab = match self.right_tab {
RightTab::Comments if self.debug_active() => RightTab::Debug,
RightTab::Debug => RightTab::Comments,
other => other,
};
}
pub fn line_coverage(&self, file: &Path, line: u32) -> crate::coverage::LineCov {
if !self.show_coverage {
return crate::coverage::LineCov::None;
}
self.coverage
.as_ref()
.map(|c| c.line_cov(file, line))
.unwrap_or(crate::coverage::LineCov::None)
}
pub fn launch_modes(&self) -> Vec<LaunchMode> {
let mut modes = Vec::new();
if self.view == ViewMode::Commits && self.open_commit.is_none() {
modes.push(LaunchMode::Commit);
}
modes.push(LaunchMode::Worktree);
modes.push(LaunchMode::Process);
modes.push(LaunchMode::Remote);
modes
}
pub fn open_launch_picker(&mut self) {
self.debug_launch_pick = Some(0);
}
pub fn close_launch_picker(&mut self) {
self.debug_launch_pick = None;
}
pub fn launch_picker_active(&self) -> bool {
self.debug_launch_pick.is_some()
}
pub fn move_launch_pick(&mut self, delta: isize) {
let len = self.launch_modes().len();
if let Some(sel) = self.debug_launch_pick.as_mut() {
let max = len as isize - 1;
*sel = ((*sel as isize + delta).clamp(0, max)) as usize;
}
}
pub fn selected_launch_mode(&self) -> Option<LaunchMode> {
let sel = self.debug_launch_pick?;
self.launch_modes().get(sel).copied()
}
pub fn open_proc_picker(&mut self) {
let procs = crate::process::list_processes().unwrap_or_default();
self.proc_picker = Some(ProcPicker {
procs,
filter: String::new(),
sel: 0,
});
}
pub fn proc_picker_active(&self) -> bool {
self.proc_picker.is_some()
}
pub fn close_proc_picker(&mut self) {
self.proc_picker = None;
}
pub fn move_proc_pick(&mut self, delta: isize) {
if let Some(p) = self.proc_picker.as_mut() {
let len = p.filtered().len();
if len == 0 {
return;
}
let max = len as isize - 1;
p.sel = ((p.sel as isize + delta).clamp(0, max)) as usize;
}
}
pub fn proc_filter_push(&mut self, ch: char) {
if let Some(p) = self.proc_picker.as_mut() {
p.filter.push(ch);
p.sel = 0;
}
}
pub fn proc_filter_backspace(&mut self) {
if let Some(p) = self.proc_picker.as_mut() {
p.filter.pop();
p.sel = 0;
}
}
pub fn selected_process(&self) -> Option<(i64, String)> {
let p = self.proc_picker.as_ref()?;
p.filtered()
.get(p.sel)
.map(|pi| (pi.pid, pi.command.clone()))
}
pub fn debug_active(&self) -> bool {
self.debug.is_some()
}
pub fn cursor_source_loc(&self) -> Option<(PathBuf, u32)> {
let file = self.selected_path()?;
let line = self.cursor_lineno()?;
Some((self.repo_root.join(file), line))
}
pub fn has_breakpoint(&self, file: &Path, line: u32) -> bool {
self.debug
.as_ref()
.and_then(|d| d.breakpoints.get(file))
.is_some_and(|lines| lines.contains_key(&line))
}
pub fn breakpoint_enabled(&self, file: &Path, line: u32) -> bool {
self.debug
.as_ref()
.and_then(|d| d.breakpoints.get(file))
.and_then(|lines| lines.get(&line))
.copied()
.unwrap_or(false)
}
pub fn toggle_breakpoint_at_cursor(&mut self) -> bool {
let Some((file, line)) = self.cursor_source_loc() else {
return false;
};
let d = self.debug.get_or_insert_with(DebugState::default);
let lines = d.breakpoints.entry(file.clone()).or_default();
let now_set = if lines.contains_key(&line) {
lines.remove(&line);
false
} else {
lines.insert(line, true); true
};
if lines.is_empty() {
d.breakpoints.remove(&file);
}
if d.breakpoints.is_empty() && d.sessions.is_empty() {
self.debug = None;
}
now_set
}
pub fn breakpoint_count(&self) -> usize {
self.debug.as_ref().map_or(0, |d| {
d.breakpoints.values().map(|m| m.len()).sum()
})
}
pub fn move_breakpoint_selection(&mut self, delta: isize) {
let len = self.breakpoint_count();
if len == 0 {
return;
}
let max = len as isize - 1;
if let Some(d) = self.debug.as_mut() {
d.bp_sel = (d.bp_sel as isize + delta).clamp(0, max) as usize;
}
}
pub fn selected_breakpoint(&self) -> Option<(PathBuf, u32, bool)> {
let d = self.debug.as_ref()?;
d.breakpoint_list().into_iter().nth(d.bp_sel)
}
pub fn toggle_selected_breakpoint(&mut self) -> Option<bool> {
let (file, line, _) = self.selected_breakpoint()?;
let d = self.debug.as_mut()?;
let on = d.breakpoints.get_mut(&file)?.get_mut(&line)?;
*on = !*on;
Some(*on)
}
pub fn delete_selected_breakpoint(&mut self) -> bool {
let Some((file, line, _)) = self.selected_breakpoint() else {
return false;
};
let Some(d) = self.debug.as_mut() else {
return false;
};
if let Some(lines) = d.breakpoints.get_mut(&file) {
lines.remove(&line);
if lines.is_empty() {
d.breakpoints.remove(&file);
}
}
let len = self.breakpoint_count();
if let Some(d) = self.debug.as_mut() {
d.bp_sel = d.bp_sel.min(len.saturating_sub(1));
}
true
}
pub fn jump_to_selected_breakpoint(&mut self) -> bool {
let Some((file, line, _)) = self.selected_breakpoint() else {
return false;
};
let cur_abs = self.selected_path().map(|p| self.repo_root.join(p));
if cur_abs.as_deref() != Some(file.as_path()) {
self.status_msg =
Some("breakpoint is in another file — open it first".into());
return false;
}
if let Some(idx) = self
.diff
.iter()
.position(|dl| dl.new_lineno == Some(line) || dl.old_lineno == Some(line))
{
self.diff_cursor = idx;
self.focus = Pane::Diff;
true
} else {
self.status_msg = Some("breakpoint line not visible in this diff".into());
false
}
}
pub fn debug_tab_is_breakpoints(&self) -> bool {
self.debug.as_ref().map(|d| d.tab) == Some(DebugTab::Breakpoints)
}
pub fn toggle_debug_tab(&mut self) {
if let Some(d) = self.debug.as_mut() {
d.tab = match d.tab {
DebugTab::Vars => DebugTab::Breakpoints,
DebugTab::Breakpoints => DebugTab::Vars,
};
}
}
pub fn move_debug_selection(&mut self, delta: isize) {
match self.debug.as_ref().map(|d| d.tab) {
Some(DebugTab::Breakpoints) => self.move_breakpoint_selection(delta),
_ => self.move_debug_panel_selection(delta),
}
}
pub fn debug_panel_len(&self) -> usize {
self.debug.as_ref().map_or(0, |d| d.debug_rows().len())
}
pub fn move_debug_panel_selection(&mut self, delta: isize) {
let len = self.debug_panel_len();
if len == 0 {
return;
}
let max = len as isize - 1;
if let Some(d) = self.debug.as_mut() {
d.panel_sel = ((d.panel_sel as isize + delta).clamp(0, max)) as usize;
}
}
pub fn toggle_expand_selected_var(&mut self) -> Option<(usize, i64, Vec<usize>)> {
let d = self.debug.as_mut()?;
let row = d.debug_rows().get(d.panel_sel).cloned()?;
let DebugRow::Var(fi, path) = row else {
return None;
};
let sess = d.sessions.get_mut(d.active)?;
let v = var_at_path_mut(&mut sess.stack.get_mut(fi)?.locals, &path)?;
if v.var_ref == 0 {
return None; }
v.expanded = !v.expanded;
if v.expanded && v.children.is_empty() {
Some((fi, v.var_ref, path))
} else {
None
}
}
pub fn set_var_expanded(&mut self, frame: usize, path: &[usize], expanded: bool) {
if let Some(d) = self.debug.as_mut() {
if let Some(sess) = d.sessions.get_mut(d.active) {
if let Some(f) = sess.stack.get_mut(frame) {
if let Some(v) = var_at_path_mut(&mut f.locals, path) {
v.expanded = expanded;
}
}
}
}
}
pub fn set_var_children(&mut self, frame: usize, path: &[usize], children: Vec<crate::dap::VarRow>) {
if let Some(d) = self.debug.as_mut() {
if let Some(sess) = d.sessions.get_mut(d.active) {
if let Some(f) = sess.stack.get_mut(frame) {
if let Some(v) = var_at_path_mut(&mut f.locals, path) {
v.children = children;
}
}
}
}
}
pub fn exit_debug(&mut self) {
if let Some(d) = self.debug.as_mut() {
d.sessions.clear();
d.active = 0;
d.panel_sel = 0;
if d.breakpoints.is_empty() {
self.debug = None;
}
}
if self.right_tab == RightTab::Debug {
self.right_tab = RightTab::Comments;
if self.is_debug_focused() {
self.focus = Pane::Diff;
}
}
if self.focus == Pane::Comments && !self.right_pane_visible() {
self.focus = Pane::Diff;
}
}
pub fn current_debug_snapshot(&self) -> Option<crate::dap::DebugSnapshot> {
let sess = self.debug.as_ref()?.active_session()?;
if sess.state != SessionState::Stopped {
return None;
}
let (file, line) = sess.stopped_at.clone()?;
const MAX_SNAPSHOT_FRAMES: usize = 8;
let stack = sess
.stack
.iter()
.take(MAX_SNAPSHOT_FRAMES)
.cloned()
.collect();
Some(crate::dap::DebugSnapshot {
session_label: sess.label.clone(),
stopped_file: file.to_string_lossy().into_owned(),
stopped_line: line,
stack,
locals: sess.locals.clone(),
captured: crate::storage::now_secs(),
})
}
pub fn attach_debug_snapshot(&mut self, snap: crate::dap::DebugSnapshot) {
let abs = PathBuf::from(&snap.stopped_file);
let rel = abs
.strip_prefix(&self.repo_root)
.map(Path::to_path_buf)
.unwrap_or(abs);
let line = snap.stopped_line;
if let Some(c) = self
.comments
.items
.iter_mut()
.find(|c| c.file == rel && c.line == line)
{
c.debug_snapshot = Some(snap);
c.updated = crate::storage::now_secs();
} else {
self.comments.set(
rel,
line,
String::new(),
String::new(),
String::new(),
vec![],
vec![],
crate::storage::now_secs(),
);
if let Some(c) = self.comments.items.last_mut() {
c.debug_snapshot = Some(snap);
}
}
}
pub fn toggle_comment_pane(&mut self) {
self.show_comments = !self.show_comments;
if self.show_comments {
self.focus = Pane::Comments;
} else if self.focus == Pane::Comments {
self.focus = Pane::Diff;
}
}
pub fn comment_rows(&self) -> Vec<CommentRow> {
use crate::comments::CommentStatus;
let order = [
CommentStatus::Open,
CommentStatus::NeedsInfo,
CommentStatus::Wontfix,
CommentStatus::Resolved,
];
let mut rows = Vec::new();
for status in &order {
let mut indices: Vec<usize> = self
.comments
.items
.iter()
.enumerate()
.filter(|(_, c)| &c.status == status)
.map(|(i, _)| i)
.collect();
if !indices.is_empty() {
indices.sort_by(|&a, &b| {
self.comments.items[b]
.updated
.cmp(&self.comments.items[a].updated)
});
rows.push(CommentRow::Header(*status, indices.len()));
for i in indices {
rows.push(CommentRow::Item(i));
}
}
}
rows
}
pub fn move_comment_selection(&mut self, delta: isize) {
let len = self.comment_rows().len();
if len == 0 {
self.comment_selected = 0;
return;
}
let max = len as isize - 1;
self.comment_selected = (self.comment_selected as isize + delta).clamp(0, max) as usize;
}
pub fn selected_comment(&self) -> Option<&crate::comments::Comment> {
match self.comment_rows().get(self.comment_selected) {
Some(CommentRow::Item(i)) => self.comments.items.get(*i),
_ => None,
}
}
pub fn select_row_for_path(&mut self, path: &Path) -> bool {
for (i, row) in self.rows.iter().enumerate() {
if let RowKind::File {
section,
file_index,
} = &row.kind
{
if let Some(fc) = self.section_files(*section).get(*file_index) {
if fc.path == path {
self.selected = i;
return true;
}
}
}
}
false
}
pub fn cursor_lineno(&self) -> Option<u32> {
self.diff
.get(self.diff_cursor)
.and_then(|l| l.new_lineno.or(l.old_lineno))
}
pub fn diff_has_lineno(&self, lineno: u32) -> bool {
self.diff
.iter()
.any(|l| l.new_lineno == Some(lineno) || l.old_lineno == Some(lineno))
}
pub fn move_cursor_to_lineno(&mut self, lineno: u32) {
for (i, dl) in self.diff.iter().enumerate() {
if dl.new_lineno == Some(lineno) {
self.diff_cursor = i;
return;
}
}
for (i, dl) in self.diff.iter().enumerate() {
if dl.old_lineno == Some(lineno) {
self.diff_cursor = i;
return;
}
}
self.diff_cursor = 0;
}
pub fn move_cursor_to_line(&mut self, new_lineno: u32) {
self.move_cursor_to_lineno(new_lineno);
}
pub fn toggle_reviewed(&mut self) {
let Some(path) = self.selected_path().cloned() else {
return;
};
if !self.reviewed.remove(&path) {
self.reviewed.insert(path);
}
self.rebuild_rows();
}
pub fn is_reviewed_path(&self, path: &Path) -> bool {
self.reviewed.contains(path)
}
pub fn is_reviewed(&self, idx: usize) -> bool {
self.unstaged
.get(idx)
.map(|f| self.reviewed.contains(&f.path))
.unwrap_or(false)
}
pub fn toggle_collapse(&mut self) {
if let Some(row) = self.rows.get(self.selected) {
if let RowKind::Dir { section, path, .. } = &row.kind {
let key = (*section, path.clone());
if self.collapsed.contains(&key) {
self.collapsed.remove(&key);
} else {
self.collapsed.insert(key);
}
self.rebuild_rows();
}
}
}
fn active_sections(&self) -> Vec<Section> {
if self.in_commit_detail() {
vec![Section::Commit]
} else {
vec![Section::Unstaged, Section::Staged]
}
}
fn all_dir_keys(&self) -> HashSet<(Section, PathBuf)> {
let mut keys = HashSet::new();
for section in self.active_sections() {
for fc in self.section_files(section) {
let mut acc = PathBuf::new();
let comps: Vec<_> = fc.path.components().collect();
for comp in comps.iter().take(comps.len().saturating_sub(1)) {
acc.push(comp);
keys.insert((section, acc.clone()));
}
}
}
keys
}
pub fn toggle_fold_all(&mut self) {
let dirs = self.all_dir_keys();
if dirs.is_empty() {
return;
}
let any_expanded = dirs.iter().any(|k| !self.collapsed.contains(k));
if any_expanded {
for k in dirs {
self.collapsed.insert(k);
}
} else {
for k in &dirs {
self.collapsed.remove(k);
}
}
self.rebuild_rows();
}
pub fn next_view(&mut self) {
let prev = self.view;
self.view = match self.view {
ViewMode::Changes => ViewMode::Commits,
ViewMode::Commits => ViewMode::Changes,
};
if prev == ViewMode::Commits && self.view == ViewMode::Changes {
self.open_commit = None;
self.commit_files.clear();
self.comment_scope = CommentScope::Worktree;
}
self.rebuild_rows();
}
pub fn prev_view(&mut self) {
self.next_view();
}
pub fn move_commit_selection(&mut self, delta: isize) {
if self.commits.is_empty() {
return;
}
let max = self.commits.len() as isize - 1;
let next = (self.selected_commit as isize + delta).clamp(0, max);
self.selected_commit = next as usize;
}
pub fn selected_commit_info(&self) -> Option<&crate::git::CommitInfo> {
self.commits.get(self.selected_commit)
}
pub fn open_commit(&mut self, id: String, files: Vec<FileChange>) {
self.comment_scope = CommentScope::Commit(id.clone());
self.commit_files = files;
self.open_commit = Some(id);
self.selected = 0;
self.rebuild_rows();
}
pub fn close_commit(&mut self) {
self.open_commit = None;
self.commit_files.clear();
self.comment_scope = CommentScope::Worktree;
self.rebuild_rows();
}
pub fn scope_label(&self) -> String {
match &self.comment_scope {
CommentScope::Worktree => "worktree".to_string(),
CommentScope::Commit(sha) => format!("commit:{}", sha),
}
}
pub fn in_commit_detail(&self) -> bool {
self.view == ViewMode::Commits && self.open_commit.is_some()
}
pub fn open_commit_short(&self) -> Option<&str> {
let id = self.open_commit.as_deref()?;
self.commits
.iter()
.find(|c| c.id == id)
.map(|c| c.short.as_str())
}
pub const MAX_FILE_HISTORY: usize = MAX_FILE_HISTORY;
pub const MAX_CONTEXT_LINES: u32 = MAX_CONTEXT_LINES;
pub fn start_history(&mut self, commits: Vec<crate::git::CommitInfo>) -> bool {
if commits.is_empty() {
return false;
}
let Some(file) = self.selected_path().cloned() else {
return false;
};
self.history = Some(FileHistory {
file,
commits,
idx: 1,
baseline_scope: self.comment_scope.clone(),
});
true
}
pub fn history_step(&mut self, delta: isize) {
if let Some(h) = self.history.as_mut() {
let max = h.commits.len() as isize;
h.idx = (h.idx as isize + delta).clamp(0, max) as usize;
}
}
pub fn history_current_commit(&self) -> Option<&crate::git::CommitInfo> {
let h = self.history.as_ref()?;
if h.idx == 0 {
None
} else {
h.commits.get(h.idx - 1)
}
}
pub fn exit_history(&mut self) {
if let Some(h) = self.history.take() {
self.comment_scope = h.baseline_scope;
}
}
pub fn history_active(&self) -> bool {
self.history.is_some()
}
pub fn search_start(&mut self) {
self.search_input = Some(String::new());
}
pub fn search_input_active(&self) -> bool {
self.search_input.is_some()
}
pub fn search_active(&self) -> bool {
self.search.is_some()
}
pub fn search_input_push(&mut self, ch: char) {
if let Some(buf) = self.search_input.as_mut() {
buf.push(ch);
}
}
pub fn search_input_backspace(&mut self) {
if let Some(buf) = self.search_input.as_mut() {
buf.pop();
}
}
pub fn search_input_cancel(&mut self) {
self.search_input = None;
}
pub fn search_clear(&mut self) {
self.search = None;
self.search_input = None;
}
pub fn search_commit(&mut self) -> bool {
let query = match self.search_input.take() {
Some(q) if !q.is_empty() => q.to_lowercase(),
_ => return false,
};
let matches: Vec<usize> = self
.diff
.iter()
.enumerate()
.filter(|(_, l)| l.text.to_lowercase().contains(&query))
.map(|(i, _)| i)
.collect();
if matches.is_empty() {
self.search = None;
return false;
}
let cur = matches
.iter()
.position(|&i| i >= self.diff_cursor)
.unwrap_or(0);
self.diff_cursor = matches[cur];
self.search = Some(SearchState {
query,
matches,
cur,
});
true
}
pub fn search_next(&mut self, delta: isize) {
if let Some(s) = self.search.as_mut() {
let len = s.matches.len() as isize;
if len == 0 {
return;
}
s.cur = ((s.cur as isize + delta) % len + len) as usize % len as usize;
self.diff_cursor = s.matches[s.cur];
}
}
pub fn inc_context(&mut self) {
if self.full_file {
return;
}
if self.context_lines >= MAX_CONTEXT_LINES {
self.full_file = true;
} else {
self.context_lines = (self.context_lines + 5).min(MAX_CONTEXT_LINES);
}
}
pub fn dec_context(&mut self) {
if self.full_file {
self.full_file = false;
return;
}
self.context_lines = self.context_lines.saturating_sub(5);
}
pub fn current_diff_line(&self) -> Option<&DiffLine> {
self.diff.get(self.diff_cursor)
}
pub fn current_hunk_header(&self) -> String {
let end = self.diff_cursor.min(self.diff.len().saturating_sub(1));
for i in (0..=end).rev() {
if self.diff[i].kind == LineKind::Hunk {
return self.diff[i].text.clone();
}
}
String::new()
}
pub fn start_comment(&mut self) {
if self.focus != Pane::Diff {
return;
}
let Some(file) = self.selected_path().cloned() else {
self.status_msg = Some("comment: no file selected".to_string());
return;
};
let Some(dl) = self.diff.get(self.diff_cursor) else {
self.status_msg = Some("comment: place cursor on a line".to_string());
return;
};
if dl.kind == LineKind::Hunk {
self.status_msg = Some("comment: place cursor on a line".to_string());
return;
}
let Some(line_no) = dl.new_lineno else {
self.status_msg = Some("comment: place cursor on a line".to_string());
return;
};
let hunk = self.current_hunk_header();
let existing = self
.comments
.get(&file, line_no)
.map(|c| c.text.clone())
.unwrap_or_default();
let (anchor_line_text, anchor_before, anchor_after) = self.comment_anchor();
let existing_snap = self
.comments
.get(&file, line_no)
.and_then(|c| c.debug_snapshot.clone());
let live_snap = self.current_debug_snapshot();
let debug_snapshot = existing_snap.or(live_snap);
let attach_debug = debug_snapshot.is_some();
self.input = Some(InputState {
buffer: existing,
target_file: file,
target_line: line_no,
target_hunk: hunk,
anchor_line_text,
anchor_before,
anchor_after,
debug_snapshot,
attach_debug,
});
}
pub fn input_active(&self) -> bool {
self.input.is_some()
}
pub fn input_push(&mut self, ch: char) {
if let Some(ref mut s) = self.input {
s.buffer.push(ch);
}
}
pub fn input_backspace(&mut self) {
if let Some(ref mut s) = self.input {
s.buffer.pop();
}
}
pub fn input_newline(&mut self) {
if let Some(ref mut s) = self.input {
s.buffer.push('\n');
}
}
pub fn input_cancel(&mut self) {
self.input = None;
}
pub fn input_commit(&mut self) -> Option<CommittedComment> {
let s = self.input.take()?;
let debug_snapshot = if s.attach_debug { s.debug_snapshot } else { None };
Some(CommittedComment {
file: s.target_file,
line: s.target_line,
hunk: s.target_hunk,
text: s.buffer,
line_text: s.anchor_line_text,
context_before: s.anchor_before,
context_after: s.anchor_after,
debug_snapshot,
})
}
pub fn input_toggle_attach_debug(&mut self) {
if let Some(s) = self.input.as_mut() {
if s.debug_snapshot.is_some() {
s.attach_debug = !s.attach_debug;
}
}
}
pub fn comment_anchor(&self) -> (String, Vec<String>, Vec<String>) {
let raw_trimmed = self
.diff
.get(self.diff_cursor)
.map(|l| l.text.trim().to_string())
.unwrap_or_default();
let line_text = if raw_trimmed.is_empty() {
"\u{0}".to_string()
} else {
raw_trimmed
};
let cursor = self.diff_cursor.min(self.diff.len());
let before: Vec<String> = self.diff[..cursor]
.iter()
.rev()
.filter(|l| l.kind != LineKind::Hunk)
.take(2)
.map(|l| l.text.trim().to_string())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let after: Vec<String> = self
.diff
.get(self.diff_cursor + 1..)
.unwrap_or(&[])
.iter()
.filter(|l| l.kind != LineKind::Hunk)
.take(2)
.map(|l| l.text.trim().to_string())
.collect();
(line_text, before, after)
}
pub fn comment_for<'a>(&'a self, line: &DiffLine) -> Option<&'a crate::comments::Comment> {
let n = line.new_lineno?;
let file = self.selected_path()?;
self.comments.get(file, n)
}
pub fn has_comment(&self, line: &DiffLine) -> bool {
if let Some(n) = line.new_lineno {
if let Some(file) = self.selected_path() {
return self.comments.get(file, n).is_some();
}
}
false
}
pub fn comment_text_for<'a>(&'a self, line: &DiffLine) -> Option<&'a str> {
let n = line.new_lineno?;
let file = self.selected_path()?;
self.comments.get(file, n).map(|c| c.text.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn sample() -> App {
let files = vec![
FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("b.rs"),
status: Status::Added,
},
FileChange {
path: PathBuf::from("c.rs"),
status: Status::Deleted,
},
];
App::new(files, vec![], PathBuf::from("/repo"))
}
#[test]
fn selection_moves_and_clamps() {
let mut app = sample();
assert_eq!(app.selected, 0);
app.move_selection(1);
assert_eq!(app.selected, 1);
app.move_selection(-5);
assert_eq!(app.selected, 0); app.move_selection(99);
assert_eq!(app.selected, 4); }
#[test]
fn focus_toggle() {
let mut app = sample();
assert_eq!(app.focus, Pane::Files);
app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
app.toggle_focus();
assert_eq!(app.focus, Pane::Files);
}
#[test]
fn gg_g_on_files_pane_moves_selection() {
let mut app = sample();
app.focus = Pane::Files;
app.to_bottom();
assert_eq!(app.selected, 4);
app.to_top();
assert_eq!(app.selected, 0);
}
#[test]
fn set_diff_resets_cursor() {
let mut app = sample();
app.set_diff(vec![DiffLine::context("x", 1, 1); 5]);
app.focus = Pane::Diff;
app.move_diff_cursor(3);
assert_eq!(app.diff_cursor, 3);
app.set_diff(vec![DiffLine::context("y", 1, 1); 2]);
assert_eq!(app.diff_cursor, 0);
}
#[test]
fn diff_cursor_moves_and_clamps() {
let mut app = sample();
app.set_diff(vec![DiffLine::context("x", 1, 1); 5]);
app.focus = Pane::Diff;
app.move_diff_cursor(-3);
assert_eq!(app.diff_cursor, 0);
app.move_diff_cursor(100);
assert_eq!(app.diff_cursor, 4);
app.to_top();
assert_eq!(app.diff_cursor, 0);
app.to_bottom();
assert_eq!(app.diff_cursor, 4);
}
#[test]
fn move_diff_cursor_skips_hunk_headers() {
let mut app = sample();
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "c0".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Hunk,
text: "@@ a @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "a2".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Hunk,
text: "@@ b @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Context,
text: "c4".into(),
old_lineno: Some(5),
new_lineno: Some(5),
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 2);
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 4);
app.move_diff_cursor(-1);
assert_eq!(app.diff_cursor, 2);
}
#[test]
fn move_diff_cursor_split_skips_del_lines() {
let mut app = sample();
app.focus = Pane::Diff;
app.split_diff = true;
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "c0".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Del,
text: "old".into(),
old_lineno: Some(2),
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "new".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Context,
text: "c3".into(),
old_lineno: Some(3),
new_lineno: Some(3),
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 2);
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 3);
app.move_diff_cursor(-1);
assert_eq!(app.diff_cursor, 2);
}
#[test]
fn move_diff_cursor_unified_does_not_skip_del() {
let mut app = sample();
app.focus = Pane::Diff;
app.split_diff = false; app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "c0".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Del,
text: "old".into(),
old_lineno: Some(2),
new_lineno: None,
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 1);
}
#[test]
fn move_diff_cursor_all_hunks_does_not_hang() {
let mut app = sample();
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ a @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Hunk,
text: "@@ b @@".into(),
old_lineno: None,
new_lineno: None,
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert!(app.diff_cursor <= 1);
}
fn three_line_diff() -> Vec<DiffLine> {
vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ a @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "alpha".into(),
old_lineno: None,
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Add,
text: "beta".into(),
old_lineno: None,
new_lineno: Some(2),
},
]
}
#[test]
fn select_range_single_when_no_anchor() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 2;
assert!(!app.select_active());
assert_eq!(app.select_range(), (2, 2));
}
#[test]
fn select_range_orders_anchor_and_cursor() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 2;
app.start_select();
app.diff_cursor = 1;
assert!(app.select_active());
assert_eq!(app.select_range(), (1, 2));
}
#[test]
fn selection_text_joins_with_trailing_newline() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 1;
app.start_select();
app.diff_cursor = 2;
assert_eq!(app.selection_text(), "alpha\nbeta\n");
}
#[test]
fn selection_text_skips_hunk_lines() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 0;
app.start_select();
app.diff_cursor = 2;
assert_eq!(app.selection_text(), "alpha\nbeta\n");
}
#[test]
fn cancel_select_clears_anchor() {
let mut app = sample();
app.set_diff(three_line_diff());
app.start_select();
assert!(app.select_active());
app.cancel_select();
assert!(!app.select_active());
assert_eq!(app.select_range(), (app.diff_cursor, app.diff_cursor));
}
#[test]
fn toggle_reviewed_tracks_selected_file() {
let mut app = sample();
app.selected = 1;
assert!(!app.is_reviewed(0));
app.toggle_reviewed();
assert!(app.is_reviewed(0));
app.toggle_reviewed();
assert!(!app.is_reviewed(0));
}
#[test]
fn hscroll_clamps_at_zero_and_resets_on_set_diff() {
let mut app = sample();
app.scroll_h(-5);
assert_eq!(app.diff_hscroll, 0); app.scroll_h(3);
assert_eq!(app.diff_hscroll, 3);
app.set_diff(vec![DiffLine::context("x", 1, 1); 2]);
assert_eq!(app.diff_hscroll, 0); }
#[test]
fn flat_files_selected_path_resolves_via_rows() {
let mut app = sample();
app.selected = 1; assert_eq!(app.selected_path(), Some(&PathBuf::from("a.rs")));
}
#[test]
fn selected_file_index_returns_correct_index() {
let mut app = sample();
app.selected = 2;
assert_eq!(app.selected_file_index(), Some(1));
}
#[test]
fn move_selection_clamps_against_rows_length() {
let mut app = sample();
app.move_selection(99);
assert_eq!(app.selected, 4); }
#[test]
fn toggle_collapse_hides_and_shows_dir_children() {
let files = vec![
FileChange {
path: PathBuf::from("src/main.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("src/ui.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("README.md"),
status: Status::Modified,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
assert_eq!(app.rows.len(), 6);
app.selected = 1;
app.toggle_collapse();
assert_eq!(app.rows.len(), 4);
assert!(matches!(
app.rows[1].kind,
crate::tree::RowKind::Dir {
collapsed: true,
..
}
));
app.selected = 1;
app.toggle_collapse();
assert_eq!(app.rows.len(), 6);
}
#[test]
fn toggle_collapse_on_file_row_does_nothing() {
let mut app = sample(); let initial_len = app.rows.len();
app.selected = 1; app.toggle_collapse();
assert_eq!(app.rows.len(), initial_len);
}
#[test]
fn fold_all_collapses_then_expands_every_dir() {
let files = vec![
FileChange {
path: PathBuf::from("src/a/x.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("src/b/y.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("top.rs"),
status: Status::Modified,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
assert_eq!(app.rows.len(), 8);
app.toggle_fold_all();
assert_eq!(app.rows.len(), 4);
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src/a"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src/b"))));
app.toggle_fold_all();
assert_eq!(app.rows.len(), 8);
assert!(app.collapsed.is_empty());
}
#[test]
fn fold_all_with_partial_collapse_collapses_remaining() {
let files = vec![
FileChange {
path: PathBuf::from("src/a/x.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("lib/b.rs"),
status: Status::Modified,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.collapsed
.insert((Section::Unstaged, PathBuf::from("lib")));
app.toggle_fold_all();
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src/a"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("lib"))));
}
#[test]
fn fold_all_no_dirs_is_noop() {
let mut app = sample(); let before = app.collapsed.len();
app.toggle_fold_all();
assert_eq!(app.collapsed.len(), before);
}
#[test]
fn selected_path_returns_none_for_dir_row() {
let files = vec![FileChange {
path: PathBuf::from("src/main.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1; assert_eq!(app.selected_path(), None);
}
#[test]
fn rebuild_rows_called_after_app_new() {
let app = sample();
assert!(!app.rows.is_empty());
assert_eq!(app.rows.len(), 5);
}
#[test]
fn to_bottom_uses_rows_length() {
let mut app = sample();
app.focus = Pane::Files;
app.to_bottom();
assert_eq!(app.selected, app.rows.len() - 1);
}
#[test]
fn hide_reviewed_hides_reviewed_file() {
let files = vec![
FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("b.rs"),
status: Status::Added,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1;
app.toggle_reviewed();
assert!(app.is_reviewed(0));
assert_eq!(app.rows.len(), 4);
app.toggle_hide_reviewed();
assert_eq!(app.rows.len(), 3);
app.toggle_hide_reviewed();
assert_eq!(app.rows.len(), 4); }
#[test]
fn rebuild_preserves_selection_identity() {
let files = vec![
FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("b.rs"),
status: Status::Added,
},
FileChange {
path: PathBuf::from("c.rs"),
status: Status::Deleted,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 3;
app.rebuild_rows();
assert_eq!(app.selected_path().unwrap(), &PathBuf::from("c.rs"));
}
#[test]
fn toggle_reviewed_on_dir_row_does_nothing() {
let files = vec![FileChange {
path: PathBuf::from("src/main.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1; app.toggle_reviewed();
assert!(app.reviewed.is_empty());
}
#[test]
fn reviewing_in_hide_mode_drops_file_and_keeps_valid_selection() {
let files = vec![
FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("b.rs"),
status: Status::Added,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.toggle_hide_reviewed(); assert_eq!(app.rows.len(), 4);
app.selected = 1;
app.toggle_reviewed();
assert_eq!(app.rows.len(), 3);
assert_eq!(app.selected_path().unwrap(), &PathBuf::from("b.rs"));
}
#[test]
fn selected_section_returns_correct_section() {
let unstaged = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let staged = vec![FileChange {
path: PathBuf::from("b.rs"),
status: Status::Added,
}];
let mut app = App::new(unstaged, staged, PathBuf::from("/repo"));
app.selected = 1;
assert_eq!(app.selected_section(), Some(Section::Unstaged));
app.selected = 3;
assert_eq!(app.selected_section(), Some(Section::Staged));
app.selected = 0; assert_eq!(app.selected_section(), None);
}
#[test]
fn effective_context_returns_context_lines_or_max() {
let mut app = sample();
assert_eq!(app.effective_context(), app.context_lines);
app.toggle_full_file();
assert_eq!(app.effective_context(), u32::MAX);
app.toggle_full_file();
assert_eq!(app.effective_context(), app.context_lines);
}
#[test]
fn context_lines_inc_dec_clamp() {
let mut app = sample();
assert_eq!(app.context_lines, 3);
app.inc_context();
assert_eq!(app.context_lines, 8);
app.dec_context();
assert_eq!(app.context_lines, 3);
for _ in 0..60 {
app.inc_context();
}
assert_eq!(app.context_lines, MAX_CONTEXT_LINES);
assert!(app.full_file);
app.dec_context();
assert!(!app.full_file);
assert_eq!(app.context_lines, MAX_CONTEXT_LINES);
for _ in 0..60 {
app.dec_context();
}
assert_eq!(app.context_lines, 0);
app.dec_context();
assert_eq!(app.context_lines, 0);
}
#[test]
fn inc_context_at_max_context_enables_full_file() {
let mut app = sample();
app.context_lines = MAX_CONTEXT_LINES;
app.inc_context();
assert!(app.full_file);
assert_eq!(app.context_lines, MAX_CONTEXT_LINES);
app.inc_context(); assert!(app.full_file);
}
#[test]
fn dec_context_from_full_file_via_f_keeps_context_lines() {
let mut app = sample();
app.context_lines = 8;
app.toggle_full_file();
app.dec_context();
assert!(!app.full_file);
assert_eq!(app.context_lines, 8);
}
#[test]
fn resize_targets_right_pane_when_focused() {
let mut app = sample();
let f0 = app.file_pane_pct;
let r0 = app.right_pane_pct;
app.focus = Pane::Comments;
app.resize_focused_pane(false); assert_eq!(app.right_pane_pct, r0 + 5);
assert_eq!(app.file_pane_pct, f0);
app.resize_focused_pane(true); assert_eq!(app.right_pane_pct, r0);
app.focus = Pane::Files;
app.resize_focused_pane(true);
assert_eq!(app.file_pane_pct, f0 + 5);
}
#[test]
fn file_pane_resize_clamps() {
let mut app = sample();
assert_eq!(app.file_pane_pct, 25);
for _ in 0..20 {
app.widen_files();
}
assert_eq!(app.file_pane_pct, 60);
for _ in 0..20 {
app.narrow_files();
}
assert_eq!(app.file_pane_pct, 10);
}
#[test]
fn toggle_files_sets_focus() {
let mut app = sample();
assert_eq!(app.show_files, true);
assert_eq!(app.focus, Pane::Files);
app.toggle_files();
assert_eq!(app.show_files, false);
assert_eq!(app.focus, Pane::Diff);
app.toggle_files();
assert_eq!(app.show_files, true);
assert_eq!(app.focus, Pane::Diff);
}
fn app_with_add_diff() -> App {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1; app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ -1,4 +1,8 @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "let x = 1;".into(),
old_lineno: None,
new_lineno: Some(2),
},
]);
app.diff_cursor = 1; app
}
#[test]
fn start_comment_sets_input_with_correct_target() {
let mut app = app_with_add_diff();
app.start_comment();
assert!(app.input.is_some());
let input = app.input.as_ref().unwrap();
assert_eq!(input.target_file, PathBuf::from("a.rs"));
assert_eq!(input.target_line, 2);
assert_eq!(input.target_hunk, "@@ -1,4 +1,8 @@");
assert_eq!(input.buffer, "");
}
#[test]
fn start_comment_prefills_buffer_with_existing_comment() {
let mut app = app_with_add_diff();
app.comments.set(
PathBuf::from("a.rs"),
2,
"@@ -1,4 +1,8 @@".to_string(),
"existing note".to_string(),
"let x = 1;".to_string(),
vec![],
vec![],
0,
);
app.start_comment();
let input = app.input.as_ref().unwrap();
assert_eq!(input.buffer, "existing note");
}
#[test]
fn start_comment_does_nothing_on_hunk_line() {
let mut app = app_with_add_diff();
app.diff_cursor = 0; app.start_comment();
assert!(app.input.is_none());
}
#[test]
fn start_comment_does_nothing_when_not_diff_focused() {
let mut app = app_with_add_diff();
app.focus = Pane::Files;
app.start_comment();
assert!(app.input.is_none());
}
#[test]
fn input_push_backspace_newline_mutate_buffer() {
let mut app = app_with_add_diff();
app.start_comment();
app.input_push('h');
app.input_push('i');
assert_eq!(app.input.as_ref().unwrap().buffer, "hi");
app.input_newline();
assert_eq!(app.input.as_ref().unwrap().buffer, "hi\n");
app.input_push('!');
assert_eq!(app.input.as_ref().unwrap().buffer, "hi\n!");
app.input_backspace();
assert_eq!(app.input.as_ref().unwrap().buffer, "hi\n");
app.input_backspace();
assert_eq!(app.input.as_ref().unwrap().buffer, "hi");
}
#[test]
fn input_commit_returns_committed_comment_and_clears_input() {
let mut app = app_with_add_diff();
app.start_comment();
app.input_push('g');
app.input_push('o');
let result = app.input_commit();
assert!(result.is_some());
let committed = result.unwrap();
assert_eq!(committed.file, PathBuf::from("a.rs"));
assert_eq!(committed.line, 2);
assert_eq!(committed.hunk, "@@ -1,4 +1,8 @@");
assert_eq!(committed.text, "go");
assert_eq!(committed.line_text, "let x = 1;");
assert!(app.input.is_none());
}
#[test]
fn current_hunk_header_finds_preceding_hunk() {
let mut app = app_with_add_diff();
app.diff_cursor = 1; let hunk = app.current_hunk_header();
assert_eq!(hunk, "@@ -1,4 +1,8 @@");
}
#[test]
fn current_hunk_header_returns_empty_when_no_hunk() {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.focus = Pane::Diff;
app.selected = 1;
app.set_diff(vec![DiffLine {
kind: LineKind::Context,
text: "ctx".into(),
old_lineno: Some(1),
new_lineno: Some(1),
}]);
app.diff_cursor = 0;
assert_eq!(app.current_hunk_header(), "");
}
#[test]
fn comment_anchor_captures_line_text_and_context() {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.focus = Pane::Diff;
app.selected = 1;
app.set_diff(vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ -1 +1 @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Context,
text: " let a = 1; ".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Add,
text: " fn target() ".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Context,
text: " let b = 2; ".into(),
old_lineno: Some(3),
new_lineno: Some(3),
},
]);
app.diff_cursor = 2;
let (line_text, before, after) = app.comment_anchor();
assert_eq!(line_text, "fn target()"); assert_eq!(before, vec!["let a = 1;"]);
assert_eq!(after, vec!["let b = 2;"]);
}
#[test]
fn comment_anchor_skips_hunk_lines_in_context() {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.focus = Pane::Diff;
app.selected = 1;
app.set_diff(vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ -1 +1 @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "first".into(),
old_lineno: None,
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Hunk,
text: "@@ -5 +5 @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "target".into(),
old_lineno: None,
new_lineno: Some(5),
},
DiffLine {
kind: LineKind::Context,
text: "after".into(),
old_lineno: Some(6),
new_lineno: Some(6),
},
]);
app.diff_cursor = 3;
let (line_text, before, after) = app.comment_anchor();
assert_eq!(line_text, "target");
assert_eq!(before, vec!["first"]);
assert_eq!(after, vec!["after"]);
}
#[test]
fn start_comment_captures_anchor_in_input_state() {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1;
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: " let a = 1; ".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Add,
text: " fn target() ".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Context,
text: " let b = 2; ".into(),
old_lineno: Some(3),
new_lineno: Some(3),
},
]);
app.diff_cursor = 1; app.start_comment();
let input = app.input.as_ref().expect("input should be active");
assert_eq!(input.anchor_line_text, "fn target()");
assert_eq!(input.anchor_before, vec!["let a = 1;"]);
assert_eq!(input.anchor_after, vec!["let b = 2;"]);
}
#[test]
fn toggle_help_flips_show_help() {
let mut app = sample();
assert!(!app.show_help);
app.toggle_help();
assert!(app.show_help);
app.toggle_help();
assert!(!app.show_help);
}
#[test]
fn toggle_theme_flips_dark_light() {
let mut app = sample();
assert_eq!(app.theme, crate::theme::Theme::Dark);
app.toggle_theme();
assert_eq!(app.theme, crate::theme::Theme::Light);
app.toggle_theme();
assert_eq!(app.theme, crate::theme::Theme::Dark);
}
#[test]
fn toggle_split_flips_flag() {
let mut app = sample();
assert!(!app.split_diff);
app.toggle_split();
assert!(app.split_diff);
app.toggle_split();
assert!(!app.split_diff);
}
#[test]
fn palette_returns_matching_palette_for_theme() {
let mut app = sample();
let dark_pal = app.palette();
assert_eq!(
dark_pal.accent,
crate::theme::Palette::for_theme(crate::theme::Theme::Dark).accent
);
app.toggle_theme();
let light_pal = app.palette();
assert_eq!(
light_pal.accent,
crate::theme::Palette::for_theme(crate::theme::Theme::Light).accent
);
assert_ne!(dark_pal.accent, light_pal.accent);
}
fn make_commit_info(summary: &str) -> crate::git::CommitInfo {
crate::git::CommitInfo {
id: "abcdef1234567890".to_string(),
short: "abcdef12".to_string(),
summary: summary.to_string(),
author: "test".to_string(),
time: "2024-01-01".to_string(),
}
}
#[test]
fn start_history_empty_commits_returns_false() {
let mut app = sample();
app.selected = 1; let ok = app.start_history(vec![]);
assert!(!ok);
assert!(app.history.is_none());
}
#[test]
fn start_history_sets_state_at_rev_one() {
let mut app = sample();
app.selected = 1; let ok = app.start_history(vec![make_commit_info("c1"), make_commit_info("c2")]);
assert!(ok);
let h = app.history.as_ref().unwrap();
assert_eq!(h.file, std::path::PathBuf::from("a.rs"));
assert_eq!(h.commits.len(), 2);
assert_eq!(h.idx, 1); assert_eq!(h.baseline_scope, CommentScope::Worktree);
}
#[test]
fn history_step_clamps_to_zero_and_len() {
let mut app = sample();
app.selected = 1;
app.start_history(vec![make_commit_info("c1"), make_commit_info("c2")]);
app.history_step(1); assert_eq!(app.history.as_ref().unwrap().idx, 2);
app.history_step(1); assert_eq!(app.history.as_ref().unwrap().idx, 2);
app.history_step(-1); app.history_step(-1); assert_eq!(app.history.as_ref().unwrap().idx, 0);
app.history_step(-1); assert_eq!(app.history.as_ref().unwrap().idx, 0);
}
#[test]
fn history_current_commit_none_at_baseline() {
let mut app = sample();
app.selected = 1;
app.start_history(vec![make_commit_info("c1")]);
assert!(app.history_current_commit().is_some()); app.history_step(-1); assert!(app.history_current_commit().is_none());
}
#[test]
fn exit_history_restores_scope_and_clears() {
let mut app = sample();
app.selected = 1;
app.comment_scope = CommentScope::Worktree;
app.start_history(vec![make_commit_info("c1")]);
app.comment_scope = CommentScope::Commit("abcdef1234567890".into());
app.exit_history();
assert!(app.history.is_none());
assert_eq!(app.comment_scope, CommentScope::Worktree);
}
fn app_with_search_diff() -> App {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1;
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "fn alpha() {".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Add,
text: "let Beta = 2;".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Context,
text: "let gamma = 3;".into(),
old_lineno: Some(3),
new_lineno: Some(3),
},
DiffLine {
kind: LineKind::Add,
text: "return beta;".into(),
old_lineno: None,
new_lineno: Some(4),
},
]);
app
}
#[test]
fn search_commit_finds_case_insensitive_matches() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "beta".chars() {
app.search_input_push(ch);
}
let ok = app.search_commit();
assert!(ok);
let s = app.search.as_ref().unwrap();
assert_eq!(s.matches, vec![1, 3]);
assert_eq!(app.diff_cursor, 1);
}
#[test]
fn search_commit_no_match_returns_false() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "zzz".chars() {
app.search_input_push(ch);
}
let ok = app.search_commit();
assert!(!ok);
assert!(app.search.is_none());
}
#[test]
fn search_next_wraps_forward_and_back() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "beta".chars() {
app.search_input_push(ch);
}
app.search_commit(); app.search_next(1); assert_eq!(app.diff_cursor, 3);
app.search_next(1); assert_eq!(app.diff_cursor, 1);
app.search_next(-1); assert_eq!(app.diff_cursor, 3);
}
#[test]
fn set_diff_clears_active_search() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "beta".chars() {
app.search_input_push(ch);
}
app.search_commit();
assert!(app.search.is_some());
app.set_diff(vec![DiffLine::context("x", 1, 1)]);
assert!(app.search.is_none());
assert!(app.search_input.is_none());
}
#[test]
fn search_input_push_backspace_cancel() {
let mut app = app_with_search_diff();
app.search_start();
app.search_input_push('a');
app.search_input_push('b');
assert_eq!(app.search_input.as_deref(), Some("ab"));
app.search_input_backspace();
assert_eq!(app.search_input.as_deref(), Some("a"));
app.search_input_cancel();
assert!(app.search_input.is_none());
assert!(app.search.is_none());
}
#[test]
fn view_mode_toggles_with_next_and_prev_view() {
let mut app = sample();
assert_eq!(app.view, ViewMode::Changes);
app.next_view();
assert_eq!(app.view, ViewMode::Commits);
app.next_view();
assert_eq!(app.view, ViewMode::Changes);
app.prev_view();
assert_eq!(app.view, ViewMode::Commits);
app.prev_view();
assert_eq!(app.view, ViewMode::Changes);
}
#[test]
fn move_commit_selection_clamps() {
let mut app = sample();
app.commits = vec![
make_commit_info("first"),
make_commit_info("second"),
make_commit_info("third"),
];
assert_eq!(app.selected_commit, 0);
app.move_commit_selection(1);
assert_eq!(app.selected_commit, 1);
app.move_commit_selection(99);
assert_eq!(app.selected_commit, 2); app.move_commit_selection(-99);
assert_eq!(app.selected_commit, 0); }
#[test]
fn input_commit_includes_anchor_fields() {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1;
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "before_line".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Add,
text: "the_target_line".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Context,
text: "after_line".into(),
old_lineno: Some(3),
new_lineno: Some(3),
},
]);
app.diff_cursor = 1;
app.start_comment();
app.input_push('n');
app.input_push('o');
app.input_push('t');
app.input_push('e');
let result = app.input_commit();
assert!(result.is_some());
let committed = result.unwrap();
assert_eq!(committed.file, PathBuf::from("a.rs"));
assert_eq!(committed.line, 2);
assert_eq!(committed.hunk, ""); assert_eq!(committed.text, "note");
assert_eq!(committed.line_text, "the_target_line");
assert_eq!(committed.context_before, vec!["before_line"]);
assert_eq!(committed.context_after, vec!["after_line"]);
assert!(app.input.is_none());
}
fn make_commit_files(paths: &[&str]) -> Vec<FileChange> {
paths
.iter()
.map(|p| FileChange {
path: PathBuf::from(p),
status: Status::Modified,
})
.collect()
}
#[test]
fn open_commit_sets_state_and_builds_commit_rows() {
let mut app = sample();
app.view = ViewMode::Commits;
app.commits = vec![crate::git::CommitInfo {
id: "aaaa1111bbbb2222".to_string(),
short: "aaaa1111".to_string(),
summary: "test commit".to_string(),
author: "tester".to_string(),
time: "2024-01-01".to_string(),
}];
let files = make_commit_files(&["src/main.rs", "lib.rs"]);
app.open_commit("aaaa1111bbbb2222".to_string(), files);
assert_eq!(app.open_commit, Some("aaaa1111bbbb2222".to_string()));
assert_eq!(app.commit_files.len(), 2);
assert_eq!(app.selected, 0);
assert!(app.in_commit_detail());
assert!(
app.rows.len() >= 3,
"expected at least 3 rows (header + files), got {}",
app.rows.len()
);
assert!(matches!(
app.rows[0].kind,
crate::tree::RowKind::Header {
section: Section::Commit,
..
}
));
let has_commit_file = app.rows.iter().any(|r| {
matches!(
&r.kind,
crate::tree::RowKind::File {
section: Section::Commit,
..
}
)
});
assert!(has_commit_file, "expected File rows with Section::Commit");
}
#[test]
fn close_commit_clears_state_and_rebuilds() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("deadbeef12345678".to_string(), files);
assert!(app.in_commit_detail());
app.close_commit();
assert_eq!(app.open_commit, None);
assert!(app.commit_files.is_empty());
assert!(!app.in_commit_detail());
assert_eq!(app.rows.len(), 5);
}
#[test]
fn switching_view_from_commit_detail_rebuilds_rows() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs", "b.rs"]);
app.open_commit("deadbeef12345678".to_string(), files);
assert!(app.in_commit_detail());
app.next_view();
assert_eq!(app.open_commit, None);
assert!(app.commit_files.is_empty());
assert!(!app.rows.iter().any(|r| matches!(
&r.kind,
crate::tree::RowKind::File {
section: Section::Commit,
..
} | crate::tree::RowKind::Header {
section: Section::Commit,
..
}
)));
assert!(app.selected < app.rows.len().max(1));
}
#[test]
fn section_files_commit_returns_commit_files() {
let mut app = sample();
let files = make_commit_files(&["x.rs", "y.rs", "z.rs"]);
app.commit_files = files.clone();
let result = app.section_files(Section::Commit);
assert_eq!(result.len(), 3);
assert_eq!(result[0].path, PathBuf::from("x.rs"));
assert_eq!(result[1].path, PathBuf::from("y.rs"));
assert_eq!(result[2].path, PathBuf::from("z.rs"));
}
fn sample_with_comments() -> App {
let files = vec![
FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("b.rs"),
status: Status::Added,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.comments.set(
PathBuf::from("a.rs"),
1,
"@@".to_string(),
"open note".to_string(),
"fn a()".to_string(),
vec![],
vec![],
0,
);
app.comments.set(
PathBuf::from("a.rs"),
5,
"@@".to_string(),
"resolved note".to_string(),
"fn b()".to_string(),
vec![],
vec![],
0,
);
app.comments.items[1].status = crate::comments::CommentStatus::Resolved;
app.comments.set(
PathBuf::from("b.rs"),
3,
"@@".to_string(),
"wontfix note".to_string(),
"fn c()".to_string(),
vec![],
vec![],
0,
);
app.comments.items[2].status = crate::comments::CommentStatus::Wontfix;
app.comments.set(
PathBuf::from("b.rs"),
7,
"@@".to_string(),
"needs info note".to_string(),
"fn d()".to_string(),
vec![],
vec![],
0,
);
app.comments.items[3].status = crate::comments::CommentStatus::NeedsInfo;
app
}
#[test]
fn comment_rows_groups_by_status_open_first() {
let app = sample_with_comments();
let rows = app.comment_rows();
assert!(!rows.is_empty(), "comment_rows must not be empty");
assert!(matches!(
rows[0],
CommentRow::Header(crate::comments::CommentStatus::Open, 1)
));
assert!(matches!(rows[1], CommentRow::Item(0)));
let ni_pos = rows.iter().position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::NeedsInfo, 1)
)
});
assert!(ni_pos.is_some(), "NeedsInfo header must be present");
let wf_pos = rows.iter().position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Wontfix, 1)
)
});
assert!(wf_pos.is_some(), "Wontfix header must be present");
let res_pos = rows.iter().position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Resolved, 1)
)
});
assert!(res_pos.is_some(), "Resolved header must be present");
let open_pos = rows
.iter()
.position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Open, _)
)
})
.unwrap();
assert!(
open_pos < ni_pos.unwrap(),
"Open must come before NeedsInfo"
);
assert!(
ni_pos.unwrap() < wf_pos.unwrap(),
"NeedsInfo must come before Wontfix"
);
assert!(
wf_pos.unwrap() < res_pos.unwrap(),
"Wontfix must come before Resolved"
);
}
#[test]
fn comment_rows_skips_empty_groups() {
let mut app = sample();
app.comments.set(
PathBuf::from("a.rs"),
1,
"@@".to_string(),
"only open".to_string(),
"fn a()".to_string(),
vec![],
vec![],
0,
);
let rows = app.comment_rows();
let has_resolved = rows.iter().any(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Resolved, _)
)
});
assert!(
!has_resolved,
"Resolved header should not appear when no resolved comments"
);
assert_eq!(rows.len(), 2);
}
#[test]
fn move_comment_selection_clamps() {
let mut app = sample_with_comments();
let row_count = app.comment_rows().len();
assert_eq!(row_count, 8);
app.move_comment_selection(100);
assert_eq!(app.comment_selected, row_count - 1);
app.move_comment_selection(-100);
assert_eq!(app.comment_selected, 0);
app.move_comment_selection(1);
assert_eq!(app.comment_selected, 1);
}
#[test]
fn selected_comment_returns_none_on_header_some_on_item() {
let mut app = sample_with_comments();
app.comment_selected = 0;
assert!(
app.selected_comment().is_none(),
"selected_comment must be None for a header row"
);
app.comment_selected = 1;
assert!(
app.selected_comment().is_some(),
"selected_comment must be Some for an item row"
);
}
#[test]
fn select_row_for_path_finds_file_row() {
let mut app = sample();
let found = app.select_row_for_path(Path::new("b.rs"));
assert!(
found,
"select_row_for_path must return true for an existing file path"
);
assert_eq!(app.selected_path(), Some(&PathBuf::from("b.rs")));
}
#[test]
fn select_row_for_path_returns_false_for_missing() {
let mut app = sample();
let found = app.select_row_for_path(Path::new("nonexistent.rs"));
assert!(
!found,
"select_row_for_path must return false for a path not in rows"
);
}
#[test]
fn cursor_lineno_prefers_new_over_old() {
let mut app = sample();
app.set_diff(vec![DiffLine {
kind: LineKind::Del,
text: "gone".into(),
old_lineno: Some(7),
new_lineno: None,
}]);
app.diff_cursor = 0;
assert_eq!(app.cursor_lineno(), Some(7));
}
#[test]
fn diff_has_lineno_matches_new_or_old() {
let mut app = sample();
app.set_diff(vec![
DiffLine {
kind: LineKind::Del,
text: "gone".into(),
old_lineno: Some(7),
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "new".into(),
old_lineno: None,
new_lineno: Some(9),
},
]);
assert!(app.diff_has_lineno(7));
assert!(app.diff_has_lineno(9));
assert!(!app.diff_has_lineno(1));
}
#[test]
fn move_cursor_to_lineno_falls_back_to_old_lineno() {
let mut app = sample();
app.set_diff(vec![DiffLine {
kind: LineKind::Del,
text: "gone".into(),
old_lineno: Some(7),
new_lineno: None,
}]);
app.move_cursor_to_lineno(7);
assert_eq!(app.diff_cursor, 0);
}
#[test]
fn move_cursor_to_line_positions_diff_cursor() {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "x".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Context,
text: "y".into(),
old_lineno: Some(2),
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Add,
text: "z".into(),
old_lineno: None,
new_lineno: Some(5),
},
]);
app.move_cursor_to_line(5);
assert_eq!(
app.diff_cursor, 2,
"diff_cursor must point to the line with new_lineno==5"
);
app.move_cursor_to_line(99);
assert_eq!(
app.diff_cursor, 0,
"cursor should reset to 0 when line not found"
);
}
#[test]
fn toggle_focus_cycles_through_visible_panes() {
let mut app = sample();
assert_eq!(app.focus, Pane::Files);
app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
app.toggle_focus();
assert_eq!(app.focus, Pane::Files);
app.show_comments = true;
app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
app.toggle_focus();
assert_eq!(app.focus, Pane::Comments);
app.toggle_focus();
assert_eq!(app.focus, Pane::Files);
}
#[test]
fn toggle_focus_only_diff_visible_stays_diff() {
let mut app = sample();
app.show_files = false;
app.show_comments = false;
app.focus = Pane::Diff;
app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
}
#[test]
fn toggle_comment_pane_flips_show_comments() {
let mut app = sample();
assert!(!app.show_comments);
app.toggle_comment_pane();
assert!(app.show_comments);
app.toggle_comment_pane();
assert!(!app.show_comments);
}
#[test]
fn toggle_comment_pane_focuses_comments_when_opening() {
let mut app = sample();
app.focus = Pane::Files;
app.toggle_comment_pane(); assert!(app.show_comments);
assert_eq!(app.focus, Pane::Comments);
}
#[test]
fn toggle_comment_pane_moves_focus_when_hiding_comments() {
let mut app = sample();
app.show_comments = true;
app.focus = Pane::Comments;
app.toggle_comment_pane();
assert!(!app.show_comments);
assert_eq!(app.focus, Pane::Diff);
}
#[test]
fn comment_rows_within_open_group_sorted_by_updated_desc() {
let mut app = sample();
app.comments.set(
PathBuf::from("a.rs"),
1,
"@@".to_string(),
"older comment".to_string(),
"fn a()".to_string(),
vec![],
vec![],
500,
);
app.comments.set(
PathBuf::from("a.rs"),
2,
"@@".to_string(),
"newer comment".to_string(),
"fn b()".to_string(),
vec![],
vec![],
1500,
);
let rows = app.comment_rows();
assert_eq!(rows.len(), 3, "Header + 2 items");
assert!(matches!(
rows[0],
CommentRow::Header(crate::comments::CommentStatus::Open, 2)
));
match rows[1] {
CommentRow::Item(idx) => {
assert_eq!(
app.comments.items[idx].text, "newer comment",
"first item in group must be the newer-updated comment"
);
}
_ => panic!("expected Item at rows[1]"),
}
match rows[2] {
CommentRow::Item(idx) => {
assert_eq!(
app.comments.items[idx].text, "older comment",
"second item in group must be the older-updated comment"
);
}
_ => panic!("expected Item at rows[2]"),
}
}
#[test]
fn open_commit_sets_comment_scope_to_commit() {
let mut app = sample();
app.view = ViewMode::Commits;
assert_eq!(app.comment_scope, CommentScope::Worktree);
let files = make_commit_files(&["a.rs"]);
app.open_commit("deadbeef1234567890abcdef".to_string(), files);
assert_eq!(
app.comment_scope,
CommentScope::Commit("deadbeef1234567890abcdef".to_string())
);
}
#[test]
fn close_commit_resets_comment_scope_to_worktree() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("deadbeef1234567890abcdef".to_string(), files);
assert_eq!(
app.comment_scope,
CommentScope::Commit("deadbeef1234567890abcdef".to_string())
);
app.close_commit();
assert_eq!(app.comment_scope, CommentScope::Worktree);
}
#[test]
fn next_view_from_commits_resets_scope_to_worktree() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("aabbccdd11223344".to_string(), files);
assert_eq!(
app.comment_scope,
CommentScope::Commit("aabbccdd11223344".to_string())
);
app.next_view();
assert_eq!(app.view, ViewMode::Changes);
assert_eq!(app.comment_scope, CommentScope::Worktree);
}
#[test]
fn scope_label_worktree() {
let app = sample();
assert_eq!(app.scope_label(), "worktree");
}
#[test]
fn scope_label_commit() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("abc123def456".to_string(), files);
assert_eq!(app.scope_label(), "commit:abc123def456");
}
fn app_on_diff_line() -> App {
let mut app = sample();
app.selected = 1; app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Add,
text: "let x = 1;".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine::context("ctx", 3, 3),
]);
app.diff_cursor = 0; app
}
#[test]
fn toggle_breakpoint_adds_then_removes() {
let mut app = app_on_diff_line();
let abs = PathBuf::from("/repo").join("a.rs");
assert!(!app.has_breakpoint(&abs, 2));
assert!(app.toggle_breakpoint_at_cursor());
assert!(app.has_breakpoint(&abs, 2));
assert!(app.debug_active());
assert!(!app.toggle_breakpoint_at_cursor());
assert!(!app.has_breakpoint(&abs, 2));
assert!(!app.debug_active());
}
#[test]
fn toggle_breakpoint_noop_without_source_line() {
let mut app = sample();
app.focus = Pane::Diff;
assert!(!app.toggle_breakpoint_at_cursor());
assert!(!app.debug_active());
}
#[test]
fn debug_panel_selection_clamps_to_rows() {
let mut app = app_on_diff_line();
let mut st = DebugState::default();
let mut sess = DebugSession::new(1, "worktree".into());
sess.stack = vec![crate::dap::Frame {
name: "main".into(),
file: None,
line: 0,
id: 0,
locals: vec![],
}];
sess.locals = vec![
crate::dap::VarRow {
name: "x".into(),
value: "1".into(),
ty: None,
var_ref: 0,
memory_ref: None,
expanded: false,
children: vec![],
},
crate::dap::VarRow {
name: "y".into(),
value: "2".into(),
ty: None,
var_ref: 0,
memory_ref: None,
expanded: false,
children: vec![],
},
];
let frame = |n: &str| crate::dap::Frame {
name: n.into(),
file: None,
line: 0,
id: 0,
locals: vec![],
};
sess.stack = vec![frame("a"), frame("b"), frame("c")];
st.sessions.push(sess);
app.debug = Some(st);
assert_eq!(app.debug_panel_len(), 3); app.focus = Pane::Comments;
app.right_tab = RightTab::Debug;
app.move_debug_panel_selection(99);
assert_eq!(app.debug.as_ref().unwrap().panel_sel, 2); app.move_debug_panel_selection(-99);
assert_eq!(app.debug.as_ref().unwrap().panel_sel, 0);
}
#[test]
fn expand_var_flattens_children_and_requests_fetch() {
let mut app = app_on_diff_line();
let mut st = DebugState::default();
let mut sess = DebugSession::new(1, "s".into());
let var = |name: &str, vref: i64| crate::dap::VarRow {
name: name.into(),
value: "..".into(),
ty: None,
var_ref: vref,
memory_ref: None,
expanded: false,
children: vec![],
};
sess.stack = vec![crate::dap::Frame {
name: "f".into(),
file: None,
line: 0,
id: 7,
locals: vec![var("s", 1004)], }];
st.sessions.push(sess);
app.debug = Some(st);
assert_eq!(app.debug_panel_len(), 2);
app.debug.as_mut().unwrap().panel_sel = 1;
let req = app.toggle_expand_selected_var();
assert_eq!(req, Some((0, 1004, vec![0])));
app.set_var_children(0, &[0], vec![var("[0]", 0), var("[1]", 0)]);
assert_eq!(app.debug_panel_len(), 4);
app.debug.as_mut().unwrap().panel_sel = 1;
assert_eq!(app.toggle_expand_selected_var(), None);
assert_eq!(app.debug_panel_len(), 2);
}
#[test]
fn attach_snapshot_creates_comment_with_stack() {
let mut app = app_on_diff_line();
let snap = crate::dap::DebugSnapshot {
session_label: "worktree".into(),
stopped_file: "/repo/a.rs".into(), stopped_line: 2,
stack: vec![crate::dap::Frame {
name: "main".into(),
file: Some("/repo/a.rs".into()),
line: 2,
id: 0,
locals: vec![],
}],
locals: vec![],
captured: 1,
};
app.attach_debug_snapshot(snap);
let c = app
.comments
.items
.iter()
.find(|c| c.file == PathBuf::from("a.rs") && c.line == 2)
.expect("comment created at stopped line");
assert!(c.debug_snapshot.is_some());
assert_eq!(c.debug_snapshot.as_ref().unwrap().stack[0].name, "main");
}
#[test]
fn breakpoint_list_toggle_and_delete() {
let mut app = app_on_diff_line();
app.toggle_breakpoint_at_cursor(); let abs = PathBuf::from("/repo").join("a.rs");
assert!(app.breakpoint_enabled(&abs, 2));
assert_eq!(app.breakpoint_count(), 1);
let on = app.toggle_selected_breakpoint();
assert_eq!(on, Some(false));
assert!(app.has_breakpoint(&abs, 2)); assert!(!app.breakpoint_enabled(&abs, 2));
assert_eq!(app.toggle_selected_breakpoint(), Some(true));
assert!(app.breakpoint_enabled(&abs, 2));
assert!(app.delete_selected_breakpoint());
assert_eq!(app.breakpoint_count(), 0);
assert!(!app.has_breakpoint(&abs, 2));
}
#[test]
fn launch_picker_modes_depend_on_view() {
let mut app = sample();
app.open_launch_picker();
assert!(app.launch_picker_active());
assert_eq!(
app.launch_modes(),
vec![LaunchMode::Worktree, LaunchMode::Process, LaunchMode::Remote]
);
assert_eq!(app.selected_launch_mode(), Some(LaunchMode::Worktree));
app.move_launch_pick(9); assert_eq!(app.selected_launch_mode(), Some(LaunchMode::Remote));
app.close_launch_picker();
assert!(!app.launch_picker_active());
app.view = ViewMode::Commits;
assert_eq!(
app.launch_modes(),
vec![
LaunchMode::Commit,
LaunchMode::Worktree,
LaunchMode::Process,
LaunchMode::Remote
]
);
}
#[test]
fn proc_picker_filters_and_selects() {
let mut app = sample();
app.proc_picker = Some(ProcPicker {
procs: vec![
crate::process::ProcInfo { pid: 100, command: "zsh".into() },
crate::process::ProcInfo { pid: 200, command: "myapp".into() },
crate::process::ProcInfo { pid: 300, command: "myapp-helper".into() },
],
filter: String::new(),
sel: 0,
});
assert!(app.proc_picker_active());
app.proc_filter_push('m');
app.proc_filter_push('y');
assert_eq!(app.proc_picker.as_ref().unwrap().filtered().len(), 2);
assert_eq!(app.selected_process(), Some((200, "myapp".into())));
app.move_proc_pick(1);
assert_eq!(app.selected_process(), Some((300, "myapp-helper".into())));
app.proc_filter_backspace();
app.proc_filter_backspace();
app.proc_filter_push('3');
assert_eq!(app.selected_process(), Some((300, "myapp-helper".into())));
}
#[test]
fn debug_tab_toggles() {
let mut app = app_on_diff_line();
app.toggle_breakpoint_at_cursor();
assert!(!app.debug_tab_is_breakpoints());
app.toggle_debug_tab();
assert!(app.debug_tab_is_breakpoints());
app.toggle_debug_tab();
assert!(!app.debug_tab_is_breakpoints());
}
#[test]
fn exit_debug_keeps_breakpoints_drops_sessions() {
let mut app = app_on_diff_line();
app.toggle_breakpoint_at_cursor(); let mut st = app.debug.take().unwrap();
st.sessions.push(DebugSession::new(1, "worktree".into()));
app.debug = Some(st);
app.focus = Pane::Comments;
app.right_tab = RightTab::Debug;
app.exit_debug();
let d = app.debug.as_ref().unwrap();
assert!(d.sessions.is_empty());
assert!(!d.breakpoints.is_empty());
assert_eq!(app.right_tab, RightTab::Comments);
assert!(!app.is_debug_focused());
}
#[test]
fn right_pane_joins_focus_cycle_when_active() {
let mut app = sample();
app.show_comments = false; app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
app.toggle_focus();
assert_eq!(app.focus, Pane::Files);
app.debug = Some(DebugState::default());
app.right_tab = RightTab::Debug;
app.focus = Pane::Diff;
app.toggle_focus();
assert_eq!(app.focus, Pane::Comments);
assert!(app.is_debug_focused());
}
#[test]
fn toggle_right_tab_only_to_debug_when_active() {
let mut app = sample();
assert_eq!(app.right_tab, RightTab::Comments);
app.toggle_right_tab(); assert_eq!(app.right_tab, RightTab::Comments);
app.debug = Some(DebugState::default());
app.toggle_right_tab();
assert_eq!(app.right_tab, RightTab::Debug);
app.toggle_right_tab();
assert_eq!(app.right_tab, RightTab::Comments);
}
}