use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::Result;
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::text::Line;
use crate::diffview::{self, DiffRender};
use crate::finder::{Finder, collect_files};
use crate::fuzzy::Fuzzy;
use crate::git::{FileStatus, GitInfo};
use crate::grep::ProjectSearch;
use crate::highlight::{self, CodeHighlighter};
use crate::keymap::{Action, Chord, Keymap};
use crate::tags::{LangConfigs, ProjectIndex, Symbol};
use crate::tree::Tree;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Tree,
Outline,
Content,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Diff,
Code,
}
struct Selection {
anchor_line: usize,
anchor_col: usize,
linewise: bool,
}
pub struct SelRegion {
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
pub linewise: bool,
}
struct FilterState {
query: String,
selected: usize,
labels: Vec<String>,
targets: Vec<FilterTarget>,
statuses: Vec<Option<FileStatus>>,
}
enum FilterTarget {
File(PathBuf),
Line(usize),
}
struct ChangedEntry {
rel: String,
abs: PathBuf,
status: FileStatus,
}
#[derive(Clone, Copy)]
enum NavList {
Changed,
AllFiles,
}
pub enum LeftPane {
Tree,
List {
title: String,
query: Option<String>,
rows: Vec<ListRow>,
},
}
pub struct ListRow {
pub label: String,
pub status: Option<FileStatus>,
pub selected: bool,
}
pub enum OutlinePane {
Empty(&'static str),
List {
query: Option<String>,
rows: Vec<OutlineRow>,
},
}
pub struct OutlineRow {
pub kind: String,
pub name: String,
pub selected: bool,
}
pub struct OpenFile {
pub path: PathBuf,
pub lines: Vec<Line<'static>>,
pub raw_lines: Vec<String>,
pub diff: Option<DiffRender>,
pub scroll: usize,
pub diff_scroll: usize,
pub cursor_line: usize,
pub cursor_col: usize,
pub outline: Vec<Symbol>,
pub outline_selected: usize,
pub change_marks: Vec<crate::diffview::LineMark>,
}
impl OpenFile {
pub fn has_diff(&self) -> bool {
self.diff.as_ref().is_some_and(|d| !d.rows.is_empty())
}
fn line_len(&self, line: usize) -> usize {
self.raw_lines.get(line).map_or(0, |l| l.chars().count())
}
fn last_line(&self) -> usize {
self.raw_lines.len().saturating_sub(1)
}
fn move_cursor_v(&mut self, delta: isize) {
let new = (self.cursor_line as isize + delta).clamp(0, self.last_line() as isize) as usize;
self.cursor_line = new;
self.cursor_col = self.cursor_col.min(self.line_len(new).saturating_sub(1));
}
fn move_cursor_h(&mut self, delta: isize) {
let max = self.line_len(self.cursor_line).saturating_sub(1);
self.cursor_col = (self.cursor_col as isize + delta).clamp(0, max as isize) as usize;
}
fn word_under_cursor(&self) -> Option<String> {
let line = self.raw_lines.get(self.cursor_line)?;
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return None;
}
let is_ident = |c: char| c.is_alphanumeric() || c == '_';
let col = self.cursor_col.min(chars.len() - 1);
if !is_ident(chars[col]) {
return None;
}
let mut start = col;
while start > 0 && is_ident(chars[start - 1]) {
start -= 1;
}
let mut end = col;
while end + 1 < chars.len() && is_ident(chars[end + 1]) {
end += 1;
}
Some(chars[start..=end].iter().collect())
}
}
pub struct App {
pub root: PathBuf,
pub focus: Focus,
pub view_mode: ViewMode,
pub tree: Tree,
pub open: Option<OpenFile>,
pub statuses: HashMap<PathBuf, FileStatus>,
pub finder: Finder,
pub grep: ProjectSearch,
pub search_query: String,
pub search_input: Option<String>,
pub diff_split: bool,
pub recenter: bool,
selection: Option<Selection>,
pub flash: Option<String>,
flash_at: Option<Instant>,
all_files: Vec<crate::finder::FileEntry>,
changed: Vec<ChangedEntry>,
changed_selected: usize,
tree_filter: Option<FilterState>,
outline_filter: Option<FilterState>,
fuzzy: Fuzzy,
lang_configs: LangConfigs,
index: ProjectIndex,
git: Option<GitInfo>,
highlighter: CodeHighlighter,
keymap: Keymap,
pending_g: bool,
should_quit: bool,
}
impl App {
pub fn new(root: PathBuf) -> Self {
let tree = Tree::new(&root);
let mut all_files = collect_files(&root);
all_files.sort_by(|a, b| a.rel.cmp(&b.rel)); let finder = Finder::from_files(all_files.clone());
let grep = ProjectSearch::new(&all_files);
let mut index = ProjectIndex::new(&root);
index.start(); let git = GitInfo::discover(&root);
let statuses = git.as_ref().map(|g| g.statuses()).unwrap_or_default();
let changed = changed_entries(&statuses, &root);
Self {
root,
focus: Focus::Tree,
view_mode: ViewMode::Code,
tree,
open: None,
statuses,
finder,
grep,
search_query: String::new(),
search_input: None,
diff_split: true,
recenter: false,
selection: None,
flash: None,
flash_at: None,
all_files,
changed,
changed_selected: 0,
tree_filter: None,
outline_filter: None,
fuzzy: Fuzzy::new(),
lang_configs: LangConfigs::new(),
index,
git,
highlighter: CodeHighlighter::new(),
keymap: Keymap::load(),
pending_g: false,
should_quit: false,
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.should_quit {
terminal.draw(|frame| crate::ui::draw(frame, self))?;
self.handle_events()?;
if self.index.poll() {
self.flash = Some("index ready".into());
}
self.expire_flash();
}
Ok(())
}
fn expire_flash(&mut self) {
const FLASH_TTL: Duration = Duration::from_millis(2500);
match (self.flash.is_some(), self.flash_at) {
(true, None) => self.flash_at = Some(Instant::now()), (true, Some(t)) if t.elapsed() >= FLASH_TTL => {
self.flash = None;
self.flash_at = None;
}
(false, _) => self.flash_at = None,
_ => {}
}
}
fn handle_events(&mut self) -> Result<()> {
if event::poll(std::time::Duration::from_millis(250))? {
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
self.on_key(key);
}
}
Ok(())
}
fn on_key(&mut self, key: KeyEvent) {
self.flash = None;
self.flash_at = None;
if self.search_input.is_some() {
self.on_key_search_input(key);
return;
}
if self.finder.active {
self.on_key_finder(key);
return;
}
if self.grep.active {
self.on_key_grep(key);
return;
}
if self.tree_filter.is_some() {
self.on_key_tree_filter(key);
return;
}
if self.outline_filter.is_some() {
self.on_key_outline_filter(key);
return;
}
if self.pending_g {
self.pending_g = false;
if self.focus == Focus::Content {
match key.code {
KeyCode::Char('g') => self.dispatch(Action::Top),
KeyCode::Char('d') => self.dispatch(Action::GotoDef),
_ => {}
}
}
return;
}
if self.focus == Focus::Content
&& key.code == KeyCode::Char('g')
&& !key.modifiers.contains(KeyModifiers::CONTROL)
{
self.pending_g = true;
return;
}
if let Some(action) = self.keymap.get(Chord::from_event(key)) {
self.dispatch(action);
}
}
fn dispatch(&mut self, action: Action) {
use Action::*;
match action {
Quit => self.should_quit = true,
FocusNext => {
self.selection = None;
self.focus = match self.focus {
Focus::Tree => Focus::Outline,
Focus::Outline => Focus::Content,
Focus::Content => Focus::Tree,
};
}
ToggleDiff => self.toggle_view_mode(),
FuzzyFind => self.finder.open(),
Grep => self.grep.open(),
Reload => self.reload(),
NextFile => self.navigate_files(self.nav_list(), true),
PrevFile => self.navigate_files(self.nav_list(), false),
ToggleSplit => {
self.diff_split = !self.diff_split;
self.ensure_diff_split();
let split = self.effective_split();
if let Some(open) = self.open.as_mut()
&& let Some(diff) = open.diff.as_ref()
{
let len = diff.row_count(split);
open.diff_scroll = open.diff_scroll.min(len.saturating_sub(1));
}
}
_ => match self.focus {
Focus::Tree => self.tree_action(action),
Focus::Outline => self.outline_action(action),
Focus::Content => self.content_action(action),
},
}
}
fn tree_action(&mut self, action: Action) {
use Action::*;
if self.view_mode == ViewMode::Diff {
match action {
Down => {
if self.changed_selected + 1 < self.changed.len() {
self.changed_selected += 1;
}
}
Up => self.changed_selected = self.changed_selected.saturating_sub(1),
Activate | Right => {
if let Some(entry) = self.changed.get(self.changed_selected) {
let path = entry.abs.clone();
self.open_file(&path);
self.focus = Focus::Content;
}
}
Find => self.open_tree_filter(),
_ => {}
}
return;
}
match action {
Down => self.tree.move_down(),
Up => self.tree.move_up(),
Left => self.tree.collapse(),
Right | Activate => {
if let Some(path) = self.tree.activate() {
self.open_file(&path);
self.focus = Focus::Content;
}
}
Find => self.open_tree_filter(),
_ => {}
}
}
fn outline_action(&mut self, action: Action) {
use Action::*;
match action {
Down => {
if let Some(open) = self.open.as_mut()
&& open.outline_selected + 1 < open.outline.len()
{
open.outline_selected += 1;
}
}
Up => {
if let Some(open) = self.open.as_mut() {
open.outline_selected = open.outline_selected.saturating_sub(1);
}
}
Activate | Right => {
let line = self
.open
.as_ref()
.and_then(|o| o.outline.get(o.outline_selected))
.map(|s| s.line);
if let Some(line) = line {
self.jump_to_code_line(line);
}
}
Find => self.open_outline_filter(),
_ => {}
}
}
fn content_action(&mut self, action: Action) {
use Action::*;
if self.view_mode == ViewMode::Diff {
match action {
SearchNext => {
self.hunk(true);
return;
}
SearchPrev => {
self.hunk(false);
return;
}
_ => {}
}
self.ensure_diff_split();
let split = self.effective_split();
let Some(open) = self.open.as_mut() else {
return;
};
let last = open
.diff
.as_ref()
.map_or(0, |d| d.row_count(split))
.saturating_sub(1);
match action {
Down => open.diff_scroll = (open.diff_scroll + 1).min(last),
Up => open.diff_scroll = open.diff_scroll.saturating_sub(1),
Top => open.diff_scroll = 0,
Bottom => open.diff_scroll = last,
HalfPageDown => open.diff_scroll = (open.diff_scroll + 15).min(last),
HalfPageUp => open.diff_scroll = open.diff_scroll.saturating_sub(15),
_ => {}
}
return;
}
match action {
Down => self.move_v(1),
Up => self.move_v(-1),
Left => {
if let Some(o) = self.open.as_mut() {
o.move_cursor_h(-1)
}
}
Right => {
if let Some(o) = self.open.as_mut() {
o.move_cursor_h(1)
}
}
WordForward => self.move_word(true),
WordBack => self.move_word(false),
LineStart => {
if let Some(o) = self.open.as_mut() {
o.cursor_col = 0
}
}
LineEnd => {
if let Some(o) = self.open.as_mut() {
o.cursor_col = o.line_len(o.cursor_line).saturating_sub(1)
}
}
Top => self.cursor_to(0),
Bottom => {
if let Some(o) = self.open.as_ref() {
self.cursor_to(o.last_line());
}
}
HalfPageDown => self.move_v(15),
HalfPageUp => self.move_v(-15),
GotoDef => self.goto_definition(),
Find => self.search_input = Some(String::new()),
SearchNext => self.search_jump(true),
SearchPrev => self.search_jump(false),
VisualChar => self.toggle_visual(false),
VisualLine => self.toggle_visual(true),
Yank => self.yank_selection(),
YankLocation => self.yank_location(),
CancelSelection => self.selection = None,
_ => {}
}
}
fn on_key_finder(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => self.finder.close(),
KeyCode::Enter => {
if let Some(path) = self.finder.selected_path() {
self.finder.close();
self.open_file(&path);
self.focus = Focus::Content;
}
}
KeyCode::Backspace => self.finder.pop_char(),
KeyCode::Down => self.finder.move_down(),
KeyCode::Up => self.finder.move_up(),
KeyCode::Char('n') if ctrl => self.finder.move_down(),
KeyCode::Char('p') if ctrl => self.finder.move_up(),
KeyCode::Char(c) if !ctrl => self.finder.push_char(c),
_ => {}
}
}
fn on_key_grep(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => self.grep.close(),
KeyCode::Enter => {
if let Some((path, line)) = self.grep.selected_target() {
let query = self.grep.query.clone();
self.grep.close();
self.open_file(&path);
self.search_query = query;
self.jump_to_code_line(line); }
}
KeyCode::Backspace => self.grep.pop_char(),
KeyCode::Down => self.grep.move_down(),
KeyCode::Up => self.grep.move_up(),
KeyCode::Char('n') if ctrl => self.grep.move_down(),
KeyCode::Char('p') if ctrl => self.grep.move_up(),
KeyCode::Char(c) if !ctrl => self.grep.push_char(c),
_ => {}
}
}
fn on_key_search_input(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => self.search_input = None,
KeyCode::Enter => {
if let Some(buf) = self.search_input.take() {
self.search_query = buf;
self.search_jump_inclusive();
}
}
KeyCode::Backspace => {
if let Some(buf) = self.search_input.as_mut() {
buf.pop();
}
}
KeyCode::Char(c) => {
if let Some(buf) = self.search_input.as_mut() {
buf.push(c);
}
}
_ => {}
}
}
fn open_tree_filter(&mut self) {
let (labels, targets, statuses) = if self.view_mode == ViewMode::Diff {
(
self.changed.iter().map(|c| c.rel.clone()).collect(),
self.changed
.iter()
.map(|c| FilterTarget::File(c.abs.clone()))
.collect(),
self.changed.iter().map(|c| Some(c.status)).collect(),
)
} else {
(
self.all_files.iter().map(|e| e.rel.clone()).collect(),
self.all_files
.iter()
.map(|e| FilterTarget::File(e.abs.clone()))
.collect(),
self.all_files
.iter()
.map(|e| self.statuses.get(&e.abs).copied())
.collect(),
)
};
self.tree_filter = Some(FilterState {
query: String::new(),
selected: 0,
labels,
targets,
statuses,
});
}
fn open_outline_filter(&mut self) {
let Some(open) = self.open.as_ref() else {
return;
};
if open.outline.is_empty() {
return;
}
self.outline_filter = Some(FilterState {
query: String::new(),
selected: 0,
labels: open.outline.iter().map(|s| s.name.clone()).collect(),
targets: open
.outline
.iter()
.map(|s| FilterTarget::Line(s.line))
.collect(),
statuses: open.outline.iter().map(|_| None).collect(),
});
}
fn on_key_tree_filter(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => self.tree_filter = None,
KeyCode::Enter => {
let target = self.filter_selected(true);
self.tree_filter = None;
if let Some(FilterTarget::File(path)) = target {
self.open_file(&path);
self.focus = Focus::Content;
}
}
KeyCode::Backspace => {
if let Some(f) = self.tree_filter.as_mut() {
f.query.pop();
f.selected = 0;
}
}
KeyCode::Up => self.move_filter_sel(true, false),
KeyCode::Down => self.move_filter_sel(true, true),
KeyCode::Char('n') if ctrl => self.move_filter_sel(true, true),
KeyCode::Char('p') if ctrl => self.move_filter_sel(true, false),
KeyCode::Char(c) if !ctrl => {
if let Some(f) = self.tree_filter.as_mut() {
f.query.push(c);
f.selected = 0;
}
}
_ => {}
}
}
fn on_key_outline_filter(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => self.outline_filter = None,
KeyCode::Enter => {
let target = self.filter_selected(false);
self.outline_filter = None;
if let Some(FilterTarget::Line(line)) = target {
self.jump_to_code_line(line);
}
}
KeyCode::Backspace => {
if let Some(f) = self.outline_filter.as_mut() {
f.query.pop();
f.selected = 0;
}
}
KeyCode::Up => self.move_filter_sel(false, false),
KeyCode::Down => self.move_filter_sel(false, true),
KeyCode::Char('n') if ctrl => self.move_filter_sel(false, true),
KeyCode::Char('p') if ctrl => self.move_filter_sel(false, false),
KeyCode::Char(c) if !ctrl => {
if let Some(f) = self.outline_filter.as_mut() {
f.query.push(c);
f.selected = 0;
}
}
_ => {}
}
}
fn move_filter_sel(&mut self, tree: bool, down: bool) {
let App {
tree_filter,
outline_filter,
fuzzy,
..
} = self;
let slot = if tree { tree_filter } else { outline_filter };
if let Some(f) = slot.as_mut() {
let n = filtered_indices(fuzzy, f).len();
if down {
if f.selected + 1 < n {
f.selected += 1;
}
} else {
f.selected = f.selected.saturating_sub(1);
}
}
}
fn filter_selected(&mut self, tree: bool) -> Option<FilterTarget> {
let App {
tree_filter,
outline_filter,
fuzzy,
..
} = self;
let f = if tree {
tree_filter.as_ref()?
} else {
outline_filter.as_ref()?
};
let idx = filtered_indices(fuzzy, f);
let sel = idx.get(f.selected).copied()?;
Some(match &f.targets[sel] {
FilterTarget::File(p) => FilterTarget::File(p.clone()),
FilterTarget::Line(l) => FilterTarget::Line(*l),
})
}
pub fn left_pane(&mut self, limit: usize) -> LeftPane {
if self.tree_filter.is_some() {
let App {
tree_filter, fuzzy, ..
} = self;
let f = tree_filter.as_ref().unwrap();
let idx = filtered_indices(fuzzy, f);
let offset = list_offset(f.selected, limit);
let rows = idx
.iter()
.skip(offset)
.take(limit)
.enumerate()
.map(|(i, &j)| ListRow {
label: f.labels[j].clone(),
status: f.statuses[j],
selected: offset + i == f.selected,
})
.collect();
return LeftPane::List {
title: "Filter".into(),
query: Some(f.query.clone()),
rows,
};
}
if self.view_mode == ViewMode::Diff {
let offset = list_offset(self.changed_selected, limit);
let rows = self
.changed
.iter()
.enumerate()
.skip(offset)
.take(limit)
.map(|(i, c)| ListRow {
label: c.rel.clone(),
status: Some(c.status),
selected: i == self.changed_selected,
})
.collect();
return LeftPane::List {
title: format!("Changed ({})", self.changed.len()),
query: None,
rows,
};
}
LeftPane::Tree
}
pub fn outline_pane(&mut self, limit: usize) -> OutlinePane {
if self.outline_filter.is_some() {
let App {
outline_filter,
fuzzy,
..
} = self;
let f = outline_filter.as_ref().unwrap();
let idx = filtered_indices(fuzzy, f);
let offset = list_offset(f.selected, limit);
let rows = idx
.iter()
.skip(offset)
.take(limit)
.enumerate()
.map(|(i, &j)| OutlineRow {
kind: String::new(),
name: f.labels[j].clone(),
selected: offset + i == f.selected,
})
.collect();
return OutlinePane::List {
query: Some(f.query.clone()),
rows,
};
}
let Some(open) = &self.open else {
return OutlinePane::Empty("(no file open)");
};
if open.outline.is_empty() {
return OutlinePane::Empty("(no symbols / unsupported language)");
}
let offset = list_offset(open.outline_selected, limit);
let rows = open
.outline
.iter()
.enumerate()
.skip(offset)
.take(limit)
.map(|(i, s)| OutlineRow {
kind: s.kind.clone(),
name: s.name.clone(),
selected: i == open.outline_selected,
})
.collect();
OutlinePane::List { query: None, rows }
}
fn move_v(&mut self, delta: isize) {
if let Some(o) = self.open.as_mut() {
o.move_cursor_v(delta);
}
}
fn move_word(&mut self, forward: bool) {
let Some(o) = self.open.as_mut() else { return };
let Some(line) = o.raw_lines.get(o.cursor_line) else {
return;
};
let chars: Vec<char> = line.chars().collect();
let is_ident = |c: char| c.is_alphanumeric() || c == '_';
let mut col = o.cursor_col;
if forward {
while col < chars.len() && is_ident(chars[col]) {
col += 1;
}
while col < chars.len() && !is_ident(chars[col]) {
col += 1;
}
o.cursor_col = col.min(chars.len().saturating_sub(1));
} else {
col = col.saturating_sub(1);
while col > 0 && !is_ident(chars[col]) {
col -= 1;
}
while col > 0 && is_ident(chars[col - 1]) {
col -= 1;
}
o.cursor_col = col;
}
}
fn cursor_to(&mut self, line: usize) {
if let Some(o) = self.open.as_mut() {
o.cursor_line = line.min(o.last_line());
o.cursor_col = o.cursor_col.min(o.line_len(o.cursor_line).saturating_sub(1));
}
}
fn jump_to_line(&mut self, line: usize) {
self.cursor_to(line);
self.recenter = true;
}
fn jump_to_code_line(&mut self, line: usize) {
self.view_mode = ViewMode::Code;
self.focus = Focus::Content;
self.jump_to_line(line);
}
fn goto_definition(&mut self) {
let Some(word) = self.open.as_ref().and_then(|o| o.word_under_cursor()) else {
return;
};
self.index.start(); match self.index.definition(&word) {
Some((path, line)) => {
let same = self.open.as_ref().is_some_and(|o| o.path == path);
if !same {
self.open_file(&path);
}
self.jump_to_code_line(line);
}
None => {
self.flash = Some(if self.index.is_building() {
"indexing… (retry gd when ready)".into()
} else {
format!("no definition: {word}")
});
}
}
}
fn search_jump(&mut self, forward: bool) {
self.do_search(forward, false);
}
fn search_jump_inclusive(&mut self) {
self.do_search(true, true);
}
fn do_search(&mut self, forward: bool, include_current: bool) {
if self.search_query.is_empty() {
return;
}
let q = self.search_query.to_lowercase();
let Some(o) = self.open.as_ref() else { return };
let n = o.raw_lines.len();
if n == 0 {
return;
}
let start = if include_current { 0 } else { 1 };
let cursor = o.cursor_line;
let mut found = None;
for step in start..n {
let idx = if forward {
(cursor + step) % n
} else {
(cursor + n - (step % n)) % n
};
if o.raw_lines[idx].to_lowercase().contains(&q) {
found = Some(idx);
break;
}
}
if let Some(idx) = found {
if let Some(o) = self.open.as_mut() {
o.cursor_line = idx;
o.cursor_col = 0;
}
self.recenter = true;
}
}
fn toggle_visual(&mut self, linewise: bool) {
match &self.selection {
Some(s) if s.linewise == linewise => self.selection = None,
_ => {
if let Some(open) = self.open.as_ref() {
self.selection = Some(Selection {
anchor_line: open.cursor_line,
anchor_col: open.cursor_col,
linewise,
});
}
}
}
}
pub fn pending_g(&self) -> bool {
self.pending_g
}
fn sync_changed_to_open(&mut self) {
let Some(path) = self.open.as_ref().map(|o| o.path.clone()) else {
return;
};
if let Some(idx) = self.changed.iter().position(|c| c.abs == path) {
self.changed_selected = idx;
}
}
pub fn ensure_diff_split(&mut self) {
if !self.effective_split() {
return;
}
let App {
open, highlighter, ..
} = self;
if let Some(o) = open.as_mut()
&& let Some(d) = o.diff.as_mut()
{
d.ensure_split(&o.lines, highlighter);
}
}
pub fn effective_split(&self) -> bool {
self.diff_split
&& self
.open
.as_ref()
.and_then(|o| o.diff.as_ref())
.is_some_and(|d| !d.single_column)
}
pub fn selection_region(&self) -> Option<SelRegion> {
let sel = self.selection.as_ref()?;
let open = self.open.as_ref()?;
let anchor = (sel.anchor_line, sel.anchor_col);
let cursor = (open.cursor_line, open.cursor_col);
let (start, end) = if anchor <= cursor {
(anchor, cursor)
} else {
(cursor, anchor)
};
Some(SelRegion {
start_line: start.0,
start_col: start.1,
end_line: end.0,
end_col: end.1,
linewise: sel.linewise,
})
}
fn yank_selection(&mut self) {
let Some(region) = self.selection_region() else {
return;
};
let Some(open) = self.open.as_ref() else {
return;
};
let text = extract_selection(&open.raw_lines, ®ion);
let lines = region.end_line - region.start_line + 1;
self.flash = Some(if copy_to_clipboard(&text) {
format!("yanked {lines} line(s)")
} else {
"clipboard error".into()
});
self.selection = None;
}
fn yank_location(&mut self) {
let Some(open) = self.open.as_ref() else {
return;
};
let rel = open
.path
.strip_prefix(&self.root)
.unwrap_or(&open.path)
.to_string_lossy();
let loc = format_location(
&rel,
self.selection_region().as_ref(),
open.cursor_line,
open.cursor_col,
);
self.flash = Some(if copy_to_clipboard(&loc) {
format!("yanked {loc}")
} else {
"clipboard error".into()
});
self.selection = None;
}
fn hunk(&mut self, forward: bool) {
if self.view_mode != ViewMode::Diff {
return;
}
self.ensure_diff_split();
let split = self.effective_split();
let Some(open) = self.open.as_ref() else {
return;
};
let Some(diff) = open.diff.as_ref() else {
return;
};
let target = next_hunk(diff.hunk_rows_for(split), open.diff_scroll, forward);
match target {
Some(t) => self.open.as_mut().unwrap().diff_scroll = t,
None => {
self.flash = Some(if forward {
"last hunk".into()
} else {
"first hunk".into()
})
}
}
}
fn nav_list(&self) -> NavList {
if self.view_mode == ViewMode::Diff {
NavList::Changed
} else {
NavList::AllFiles
}
}
fn navigate_files(&mut self, list: NavList, forward: bool) {
let len = match list {
NavList::Changed => self.changed.len(),
NavList::AllFiles => self.all_files.len(),
};
if len == 0 {
return;
}
let last = len - 1;
let cur = match list {
NavList::Changed => Some(self.changed_selected.min(last)),
NavList::AllFiles => self
.open
.as_ref()
.and_then(|o| self.all_files.iter().position(|e| e.abs == o.path)),
};
let next = match cur {
Some(i) if forward => (i + 1).min(last),
Some(i) => i.saturating_sub(1),
None if forward => 0,
None => last,
};
if Some(next) == cur {
let (fwd, bwd) = match list {
NavList::Changed => ("last changed file", "first changed file"),
NavList::AllFiles => ("last file", "first file"),
};
self.flash = Some(if forward { fwd } else { bwd }.into());
return;
}
let path = match list {
NavList::Changed => {
self.changed_selected = next;
self.changed[next].abs.clone()
}
NavList::AllFiles => self.all_files[next].abs.clone(),
};
self.open_file(&path);
self.focus = Focus::Content;
}
fn reload(&mut self) {
self.statuses = self.git.as_ref().map(|g| g.statuses()).unwrap_or_default();
self.changed = changed_entries(&self.statuses, &self.root);
if self.changed_selected >= self.changed.len() {
self.changed_selected = self.changed.len().saturating_sub(1);
}
self.tree = Tree::new(&self.root);
self.all_files = collect_files(&self.root);
self.all_files.sort_by(|a, b| a.rel.cmp(&b.rel));
self.finder = Finder::from_files(self.all_files.clone());
self.grep = ProjectSearch::new(&self.all_files);
self.index = ProjectIndex::new(&self.root);
self.index.start();
if let Some(open) = self.open.as_ref() {
let path = open.path.clone();
let (cl, cc, scroll, diff_scroll) =
(open.cursor_line, open.cursor_col, open.scroll, open.diff_scroll);
self.open_file(&path);
self.ensure_diff_split();
let split = self.effective_split();
if let Some(o) = self.open.as_mut() {
o.cursor_line = cl.min(o.last_line());
o.cursor_col = cc.min(o.line_len(o.cursor_line).saturating_sub(1));
o.scroll = scroll.min(o.lines.len().saturating_sub(1));
let diff_rows = o.diff.as_ref().map_or(0, |d| d.row_count(split));
o.diff_scroll = diff_scroll.min(diff_rows.saturating_sub(1));
}
}
self.flash = Some("reloaded".into());
}
fn toggle_view_mode(&mut self) {
self.selection = None;
let next = match self.view_mode {
ViewMode::Diff => ViewMode::Code,
ViewMode::Code => ViewMode::Diff,
};
let map_lines = !self.effective_split();
if let Some(open) = self.open.as_mut()
&& map_lines
{
match (self.view_mode, next) {
(ViewMode::Diff, ViewMode::Code) => {
if let Some(diff) = &open.diff {
let line = code_line_at_or_after(&diff.to_code, open.diff_scroll)
.unwrap_or(open.cursor_line);
open.cursor_line = line.min(open.last_line());
}
}
(ViewMode::Code, ViewMode::Diff) => {
if let Some(diff) = &open.diff {
open.diff_scroll = diff_row_for_code(&diff.to_code, open.cursor_line)
.unwrap_or(open.diff_scroll);
}
}
_ => {}
}
}
self.view_mode = next;
match next {
ViewMode::Code => {
if let Some(path) = self.open.as_ref().map(|o| o.path.clone()) {
self.tree.reveal(&path);
}
}
ViewMode::Diff => self.sync_changed_to_open(),
}
}
fn open_file(&mut self, path: &Path) {
let syntax = highlight::detect_syntax(path);
let (lines, raw_lines, outline) = match std::fs::read_to_string(path) {
Ok(source) => {
let source = source.replace("\r\n", "\n");
let lines = self.highlighter.highlight(syntax, &source);
let raw_lines = source.split('\n').map(|s| s.to_string()).collect();
let outline = self.lang_configs.file_symbols(path, source.as_bytes());
(lines, raw_lines, outline)
}
Err(_) => (
vec![Line::from("(binary or unreadable file)")],
Vec::new(),
Vec::new(),
),
};
let diff = self
.git
.as_ref()
.and_then(|g| g.diff_file(path))
.filter(|d| !d.is_empty())
.map(|d| diffview::build(&d, &lines, &mut self.highlighter, syntax));
if diff.is_none() {
self.view_mode = ViewMode::Code;
}
let change_marks = diff
.as_ref()
.map(|d| d.line_marks(lines.len()))
.unwrap_or_default();
self.selection = None;
self.open = Some(OpenFile {
path: path.to_path_buf(),
lines,
raw_lines,
diff,
scroll: 0,
diff_scroll: 0,
cursor_line: 0,
cursor_col: 0,
outline,
outline_selected: 0,
change_marks,
});
self.tree.reveal(path);
self.sync_changed_to_open();
}
}
fn next_hunk(hunk_rows: &[usize], cur: usize, forward: bool) -> Option<usize> {
if forward {
hunk_rows.iter().copied().find(|&r| r > cur)
} else {
hunk_rows.iter().copied().rev().find(|&r| r < cur)
}
}
fn format_location(
rel: &str,
region: Option<&SelRegion>,
cursor_line: usize,
cursor_col: usize,
) -> String {
match region {
Some(r) if r.end_line != r.start_line => {
format!("{rel}:{}-{}", r.start_line + 1, r.end_line + 1)
}
Some(r) => format!("{rel}:{}", r.start_line + 1),
None => format!("{rel}:{}:{}", cursor_line + 1, cursor_col + 1),
}
}
fn extract_selection(raw: &[String], r: &SelRegion) -> String {
let line_chars = |i: usize| -> Vec<char> { raw.get(i).map(|s| s.chars().collect()).unwrap_or_default() };
if r.linewise {
return (r.start_line..=r.end_line)
.filter_map(|i| raw.get(i).cloned())
.collect::<Vec<_>>()
.join("\n");
}
if r.start_line == r.end_line {
let chars = line_chars(r.start_line);
let end = (r.end_col + 1).min(chars.len());
let start = r.start_col.min(end);
return chars[start..end].iter().collect();
}
let mut out = String::new();
for i in r.start_line..=r.end_line {
let chars = line_chars(i);
let piece: String = if i == r.start_line {
chars[r.start_col.min(chars.len())..].iter().collect()
} else if i == r.end_line {
let end = (r.end_col + 1).min(chars.len());
chars[..end].iter().collect()
} else {
chars.iter().collect()
};
out.push_str(&piece);
if i != r.end_line {
out.push('\n');
}
}
out
}
fn copy_to_clipboard(text: &str) -> bool {
match arboard::Clipboard::new() {
Ok(mut cb) => cb.set_text(text.to_string()).is_ok(),
Err(_) => false,
}
}
fn changed_entries(statuses: &HashMap<PathBuf, FileStatus>, root: &Path) -> Vec<ChangedEntry> {
let mut entries: Vec<ChangedEntry> = statuses
.iter()
.map(|(abs, status)| ChangedEntry {
rel: abs
.strip_prefix(root)
.unwrap_or(abs)
.to_string_lossy()
.to_string(),
abs: abs.clone(),
status: *status,
})
.collect();
entries.sort_by(|a, b| a.rel.cmp(&b.rel));
entries
}
fn filtered_indices(fuzzy: &mut Fuzzy, f: &FilterState) -> Vec<usize> {
let refs: Vec<&str> = f.labels.iter().map(|s| s.as_str()).collect();
fuzzy.rank(&f.query, &refs)
}
fn list_offset(selected: usize, limit: usize) -> usize {
if limit > 0 && selected >= limit {
selected - limit + 1
} else {
0
}
}
fn code_line_at_or_after(to_code: &[Option<usize>], from: usize) -> Option<usize> {
to_code.iter().skip(from).find_map(|c| *c)
}
fn diff_row_for_code(to_code: &[Option<usize>], code_idx: usize) -> Option<usize> {
if let Some(pos) = to_code.iter().position(|c| *c == Some(code_idx)) {
return Some(pos);
}
to_code.iter().position(|c| c.is_some_and(|c| c >= code_idx))
}
#[cfg(test)]
mod tests {
use super::*;
const MAP: &[Option<usize>] = &[None, Some(0), Some(1), None, Some(2)];
#[test]
fn diff_to_code_skips_non_code_rows() {
assert_eq!(code_line_at_or_after(MAP, 0), Some(0));
assert_eq!(code_line_at_or_after(MAP, 3), Some(2));
}
#[test]
fn code_to_diff_finds_exact_then_following() {
assert_eq!(diff_row_for_code(MAP, 1), Some(2));
assert_eq!(diff_row_for_code(&[None, Some(2), Some(5)], 3), Some(2));
}
fn open_with(lines: &[&str]) -> OpenFile {
OpenFile {
path: PathBuf::from("x.rs"),
lines: lines.iter().map(|l| Line::from(l.to_string())).collect(),
raw_lines: lines.iter().map(|l| l.to_string()).collect(),
diff: None,
scroll: 0,
diff_scroll: 0,
cursor_line: 0,
cursor_col: 0,
outline: Vec::new(),
outline_selected: 0,
change_marks: Vec::new(),
}
}
#[test]
fn word_under_cursor_extracts_identifier() {
let mut o = open_with(&["let value = compute();"]);
o.cursor_col = 5; assert_eq!(o.word_under_cursor().as_deref(), Some("value"));
o.cursor_col = 12; assert_eq!(o.word_under_cursor().as_deref(), Some("compute"));
}
#[test]
fn vertical_move_clamps_column() {
let mut o = open_with(&["longest line here", "x"]);
o.cursor_col = 10;
o.move_cursor_v(1);
assert_eq!(o.cursor_line, 1);
assert_eq!(o.cursor_col, 0); }
#[test]
fn tree_filter_narrows_files() {
let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
app.view_mode = ViewMode::Code;
app.open_tree_filter();
if let Some(f) = app.tree_filter.as_mut() {
f.query = "apprs".into();
}
match app.left_pane(100) {
LeftPane::List { query, rows, .. } => {
assert_eq!(query.as_deref(), Some("apprs"));
assert!(
rows.iter().any(|r| r.label.contains("app.rs")),
"filtered rows should include app.rs"
);
}
_ => panic!("expected filtered list"),
}
}
#[test]
fn next_hunk_finds_neighbor_or_none() {
let rows = [2usize, 10, 25];
assert_eq!(next_hunk(&rows, 0, true), Some(2));
assert_eq!(next_hunk(&rows, 2, true), Some(10));
assert_eq!(next_hunk(&rows, 25, true), None); assert_eq!(next_hunk(&rows, 25, false), Some(10));
assert_eq!(next_hunk(&rows, 2, false), None); assert_eq!(next_hunk(&[], 5, true), None);
}
#[test]
fn location_string_handles_selection_and_cursor() {
let none: Option<&SelRegion> = None;
assert_eq!(format_location("a/b.rs", none, 9, 4), "a/b.rs:10:5");
let single = SelRegion {
start_line: 9,
start_col: 0,
end_line: 9,
end_col: 3,
linewise: false,
};
assert_eq!(format_location("a/b.rs", Some(&single), 9, 3), "a/b.rs:10");
let multi = SelRegion {
start_line: 9,
start_col: 2,
end_line: 19,
end_col: 0,
linewise: false,
};
assert_eq!(format_location("a/b.rs", Some(&multi), 19, 0), "a/b.rs:10-20");
}
#[test]
fn extract_selection_charwise_and_linewise() {
let raw = vec![
"abcdef".to_string(),
"ghijkl".to_string(),
"mnopqr".to_string(),
];
let r = SelRegion {
start_line: 0,
start_col: 1,
end_line: 0,
end_col: 3,
linewise: false,
};
assert_eq!(extract_selection(&raw, &r), "bcd");
let r = SelRegion {
start_line: 0,
start_col: 4,
end_line: 2,
end_col: 1,
linewise: false,
};
assert_eq!(extract_selection(&raw, &r), "ef\nghijkl\nmn");
let r = SelRegion {
start_line: 0,
start_col: 0,
end_line: 1,
end_col: 0,
linewise: true,
};
assert_eq!(extract_selection(&raw, &r), "abcdef\nghijkl");
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let ok = std::process::Command::new("git").args(args).current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null").env("GIT_CONFIG_SYSTEM", "/dev/null")
.status().unwrap().success();
assert!(ok, "git {args:?}");
}
#[test]
fn change_marker_renders_for_modified_line() {
use crate::diffview::LineMark;
let dir = std::env::temp_dir().join(format!("srev_mark_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
run_git(&dir, &["init", "-q"]);
run_git(&dir, &["config", "user.email", "t@e.com"]);
run_git(&dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("code.rs"), "fn a() {}
fn b() {}
fn c() {}
").unwrap();
run_git(&dir, &["add", "."]);
run_git(&dir, &["commit", "-q", "-m", "first"]);
std::fs::write(dir.join("code.rs"), "fn a() {}
fn B() {}
fn c() {}
").unwrap();
let dir = std::fs::canonicalize(&dir).unwrap();
let mut app = App::new(dir.clone());
let path = dir.join("code.rs");
app.open_file(&path);
let marks = &app.open.as_ref().unwrap().change_marks;
assert!(marks.iter().any(|m| *m != LineMark::None), "marks not computed: {marks:?}");
let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
term.draw(|f| crate::ui::draw(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let has_marker = buf
.content()
.iter()
.any(|c| c.symbol() == "\u{258c}" || c.symbol() == "\u{2594}");
let modbg = ratatui::style::Color::Rgb(26, 34, 58);
let mut max_run = 0;
for y in 0..buf.area.height {
let run = (0..buf.area.width).filter(|&x| buf[(x, y)].bg == modbg).count();
max_run = max_run.max(run);
}
let _ = std::fs::remove_dir_all(&dir);
assert!(has_marker, "no change marker rendered");
assert!(max_run >= 10, "changed-line background not filled (max run = {max_run})");
}
#[test]
fn flash_records_then_expires() {
let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
app.flash = Some("hi".into());
app.flash_at = None;
app.expire_flash(); assert!(app.flash.is_some() && app.flash_at.is_some());
app.expire_flash(); assert!(app.flash.is_some());
app.flash_at = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(10));
app.expire_flash();
assert!(app.flash.is_none() && app.flash_at.is_none());
}
#[test]
fn file_move_navigates_all_files_in_order() {
let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
if app.all_files.len() < 2 {
return;
}
app.navigate_files(NavList::AllFiles, true); assert_eq!(app.open.as_ref().unwrap().path, app.all_files[0].abs);
app.navigate_files(NavList::AllFiles, true); assert_eq!(app.open.as_ref().unwrap().path, app.all_files[1].abs);
app.navigate_files(NavList::AllFiles, false); assert_eq!(app.open.as_ref().unwrap().path, app.all_files[0].abs);
}
#[test]
fn navigate_changed_advances_selection() {
let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
if app.changed.len() < 2 {
return;
}
app.view_mode = ViewMode::Diff;
app.changed_selected = 0;
app.navigate_files(NavList::Changed, true);
assert_eq!(app.changed_selected, 1);
assert_eq!(app.open.as_ref().unwrap().path, app.changed[1].abs);
app.navigate_files(NavList::Changed, false);
assert_eq!(app.changed_selected, 0);
}
#[test]
fn changed_list_syncs_to_open_file() {
let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
if let Some(path) = app.changed.first().map(|c| c.abs.clone()) {
app.open_file(&path);
assert_eq!(app.changed[app.changed_selected].abs, path);
}
}
#[test]
fn diff_mode_left_pane_is_changed_list() {
let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
app.view_mode = ViewMode::Diff;
match app.left_pane(100) {
LeftPane::List { title, query, .. } => {
assert!(title.starts_with("Changed"), "title was {title}");
assert!(query.is_none());
}
_ => panic!("expected changed list in diff mode"),
}
}
}