use diffler_core::highlight::StyledRange;
use diffler_core::vcs::BlameSpan;
use super::fuzzy::{FuzzyKey, FuzzyList, name_haystack, selected};
use super::{App, Flow, Modal, Screen};
use crate::keymap::Action;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileAction {
View,
Blame,
Editor,
}
#[derive(Debug, Clone)]
pub struct FileOpen {
pub path: String,
pub line: Option<u32>,
pub blame: bool,
pub token: u64,
}
#[derive(Debug)]
pub struct FileView {
pub path: String,
pub lines: Vec<String>,
pub highlights: Vec<Vec<StyledRange>>,
pub spans: Vec<BlameSpan>,
span_of_line: Vec<Option<usize>>,
pub cursor: usize,
pub scroll: usize,
pub show_blame: bool,
pub viewport: u16,
}
impl FileView {
pub fn new(
path: String,
content: &str,
highlights: Vec<Vec<StyledRange>>,
spans: Vec<BlameSpan>,
show_blame: bool,
) -> Self {
let lines: Vec<String> = content.lines().map(str::to_owned).collect();
let span_of_line = index_spans(&spans, lines.len());
Self {
path,
lines,
highlights,
spans,
span_of_line,
cursor: 0,
scroll: 0,
show_blame,
viewport: 0,
}
}
pub fn span_at(&self, line: usize) -> Option<&BlameSpan> {
self.span_of_line
.get(line)
.copied()
.flatten()
.and_then(|index| self.spans.get(index))
}
pub fn starts_span(&self, line: usize) -> bool {
let here = self.span_of_line.get(line).copied().flatten();
let above = line
.checked_sub(1)
.and_then(|prev| self.span_of_line.get(prev).copied().flatten());
here.is_some() && here != above
}
pub fn cursor_commit(&self) -> Option<&BlameSpan> {
self.span_at(self.cursor).filter(|span| span.committed)
}
fn step_span(&mut self, forward: bool) {
let mut line = self.cursor;
loop {
let next = if forward {
line + 1
} else {
match line.checked_sub(1) {
Some(next) => next,
None => return,
}
};
if next >= self.lines.len() {
return;
}
line = next;
if self.starts_span(line) {
self.cursor = line;
return;
}
}
}
}
fn index_spans(spans: &[BlameSpan], line_count: usize) -> Vec<Option<usize>> {
let mut out = vec![None; line_count];
for (index, span) in spans.iter().enumerate() {
let start = span.start_line.saturating_sub(1) as usize;
let end = start.saturating_add(span.line_count as usize);
for slot in out.iter_mut().take(end).skip(start) {
*slot = Some(index);
}
}
out
}
impl App {
pub(crate) fn open_file(&mut self, path: &str, line: Option<u32>, blame: bool) {
self.file_token += 1;
self.pending_file = Some(FileOpen {
path: path.to_owned(),
line,
blame,
token: self.file_token,
});
self.info(format!("opening {path}"));
}
pub(crate) fn cancel_file_load(&mut self) {
self.file_token += 1;
self.pending_file = None;
}
pub(crate) fn blame_focused(&mut self) {
let target = match self.screen() {
Screen::Diff => self.diff_cursor_file_line(),
Screen::Status => self.status_cursor_file_line(),
Screen::File => self
.file
.as_ref()
.map(|view| (view.path.clone(), Some(view.cursor as u32 + 1))),
Screen::Log | Screen::Runs | Screen::Graph | Screen::Prs | Screen::CiLog => None,
};
let Some((path, line)) = target else {
self.info("no file under the cursor");
return;
};
self.open_file(&path, line, true);
}
pub(crate) fn on_file_loaded(
&mut self,
result: Result<FileView, String>,
line: Option<u32>,
token: u64,
) -> Flow {
if token != self.file_token {
return Flow::Idle;
}
match result {
Ok(view) => self.install_file(view, line),
Err(err) => self.error(err),
}
Flow::Continue
}
pub(crate) fn install_file(&mut self, view: FileView, line: Option<u32>) {
let mut view = view;
if let Some(line) = line {
view.cursor = (line.saturating_sub(1) as usize).min(view.lines.len().saturating_sub(1));
}
self.file = Some(view);
self.message = None;
if self.screen() != Screen::File {
self.push_screen(Screen::File);
}
}
pub(super) fn dispatch_file(&mut self, action: Action) {
let Some(view) = self.file.as_mut() else {
return;
};
let last = view.lines.len().saturating_sub(1);
let height = usize::from(view.viewport);
let page = |full| super::page_step(view.viewport, full);
match action {
Action::MoveDown => view.cursor = (view.cursor + 1).min(last),
Action::MoveUp => view.cursor = view.cursor.saturating_sub(1),
Action::GoTop => view.cursor = 0,
Action::GoBottom => view.cursor = last,
Action::HalfPageDown => view.cursor = (view.cursor + page(false)).min(last),
Action::HalfPageUp => view.cursor = view.cursor.saturating_sub(page(false)),
Action::FullPageDown => view.cursor = (view.cursor + page(true)).min(last),
Action::FullPageUp => view.cursor = view.cursor.saturating_sub(page(true)),
Action::CenterCursor => view.scroll = view.cursor.saturating_sub(height / 2),
Action::CursorTop => view.scroll = view.cursor,
Action::CursorBottom => {
view.scroll = view.cursor.saturating_sub(height.saturating_sub(1));
}
Action::NextSection => view.step_span(true),
Action::PrevSection => view.step_span(false),
Action::ToggleBlame => view.show_blame = !view.show_blame,
Action::Open => self.open_cursor_commit(),
Action::OpenEditor => {
let (path, line) = (view.path.clone(), view.cursor as u32 + 1);
self.request_editor(&path, Some(line));
}
_ => {}
}
}
fn open_cursor_commit(&mut self) {
let Some(oid) = self
.file
.as_ref()
.and_then(|view| view.cursor_commit().map(|span| span.oid.clone()))
else {
self.info("no commit for this line");
return;
};
self.open_commit_diff(&oid);
}
pub(crate) fn open_file_picker(&mut self) {
let files = match self.review.vcs.tracked_files() {
Ok(files) => files,
Err(err) => {
self.info(format!("cannot list files: {err}"));
return;
}
};
if files.is_empty() {
self.info("no tracked files");
return;
}
let paths: Vec<String> = files
.iter()
.map(|path| path.to_string_lossy().replace('\\', "/"))
.collect();
let mut list = FuzzyList::typing();
list.rerank(&name_haystack(&paths));
self.modal = Some(Modal::FilePicker { paths, list });
}
pub(super) fn handle_file_picker_key(&mut self, key: &crossterm::event::KeyEvent) -> Flow {
let Some(Modal::FilePicker { paths, list }) = self.modal.as_mut() else {
return Flow::Continue;
};
match list.feed(key) {
FuzzyKey::Submit => self.take_picked_file(FileAction::View),
FuzzyKey::Cancel => self.modal = None,
FuzzyKey::Edited => {
let haystack = name_haystack(paths);
list.rerank(&haystack);
}
FuzzyKey::Other => match key.code {
crossterm::event::KeyCode::Char('b') => self.take_picked_file(FileAction::Blame),
crossterm::event::KeyCode::Char('e') => self.take_picked_file(FileAction::Editor),
_ => {}
},
FuzzyKey::Consumed => {}
}
Flow::Continue
}
#[cfg(test)]
pub(crate) fn picker_paths(&self) -> Vec<String> {
match &self.modal {
Some(Modal::FilePicker { paths, .. }) => paths.clone(),
_ => Vec::new(),
}
}
pub(crate) fn take_picked_file(&mut self, action: FileAction) {
let Some(Modal::FilePicker { paths, list }) = &self.modal else {
return;
};
let Some(path) = selected(list, paths).cloned() else {
return;
};
self.modal = None;
match action {
FileAction::View => self.open_file(&path, None, false),
FileAction::Blame => self.open_file(&path, None, true),
FileAction::Editor => self.request_editor(&path, None),
}
}
}
#[cfg(test)]
mod tests {
use diffler_core::vcs::BlameSpan;
use super::*;
use crate::config::LoadedConfig;
use crate::test_support::{Fixture, ctrl_key, key, standard_fixture};
fn app() -> (Fixture, App) {
let fixture = standard_fixture();
let app = App::new(fixture.review(), LoadedConfig::default());
(fixture, app)
}
fn span(start: u32, count: u32, committed: bool) -> BlameSpan {
BlameSpan {
start_line: start,
line_count: count,
oid: "a".repeat(40),
oid7: "aaaaaaa".to_owned(),
author: "reviewer".to_owned(),
time_unix: 0,
summary: "base".to_owned(),
committed,
}
}
fn view(text: &str, spans: Vec<BlameSpan>) -> FileView {
FileView::new("src/lib.rs".to_owned(), text, Vec::new(), spans, true)
}
#[test]
fn the_picker_lists_tracked_files_and_enter_opens_the_one_selected() {
let (_fixture, mut app) = app();
app.handle(ctrl_key('t'));
assert_eq!(
app.picker_paths(),
vec!["ci.yml", "notes.txt", "src/lib.rs"],
"untracked todo.md stays out; staged ci.yml is in"
);
app.handle(key('\n'));
let request = app.pending_file.as_ref().expect("a queued file");
assert_eq!(request.path, "ci.yml");
assert!(!request.blame, "enter opens the file, blame stays off");
}
#[test]
fn the_picker_filters_on_typing_and_b_opens_the_match_with_blame() {
let (_fixture, mut app) = app();
app.handle(ctrl_key('t'));
for c in "lib".chars() {
app.handle(key(c));
}
app.handle(key('\t'));
app.handle(key('b'));
let request = app.pending_file.as_ref().expect("a queued file");
assert_eq!(request.path, "src/lib.rs");
assert!(request.blame);
}
#[test]
fn the_picker_sends_a_file_to_the_editor_without_opening_the_view() {
let (_fixture, mut app) = app();
app.handle(ctrl_key('t'));
app.handle(key('\t'));
app.handle(key('e'));
assert!(
app.pending_file.is_none(),
"the editor never loads the view"
);
assert!(app.pending_editor.is_some());
}
fn seek(app: &mut App, ready: impl Fn(&App) -> bool) {
for _ in 0..40 {
if ready(app) {
return;
}
app.handle(key('j'));
}
panic!("never reached the row the test needs");
}
#[test]
fn blame_from_the_status_cursor_targets_the_file_under_it() {
let (_fixture, mut app) = app();
seek(&mut app, |app| app.status_cursor_file_line().is_some());
let expected = app.status_cursor_file_line().expect("a file row").0;
app.handle(key('B'));
let request = app.pending_file.as_ref().expect("a queued file");
assert_eq!(request.path, expected);
assert!(request.blame);
}
#[test]
fn blame_from_the_diff_cursor_carries_the_line_under_it() {
let (_fixture, mut app) = app();
app.open_working_tree_file("src/lib.rs");
seek(&mut app, |app| {
app.diff_cursor_file_line()
.is_some_and(|(_, line)| line.is_some())
});
let (path, line) = app.diff_cursor_file_line().expect("a diff line");
app.handle(key('B'));
let request = app.pending_file.as_ref().expect("a queued file");
assert_eq!((request.path.clone(), request.line), (path, line));
assert!(request.blame);
}
#[test]
fn a_span_covers_its_run_and_only_its_first_line_starts_it() {
let view = view(
"one\ntwo\nthree\n",
vec![span(1, 2, true), span(3, 1, true)],
);
assert!(view.starts_span(0));
assert!(!view.starts_span(1), "the run continues");
assert!(view.starts_span(2), "a new commit starts a new run");
assert_eq!(view.span_at(1).map(|s| s.start_line), Some(1));
}
#[test]
fn a_line_no_span_covers_has_no_commit() {
let view = view("one\ntwo\n", vec![span(1, 1, true)]);
assert!(view.span_at(1).is_none());
assert!(!view.starts_span(1));
}
#[test]
fn an_uncommitted_line_offers_no_commit_to_open() {
let mut view = view("one\n", vec![span(1, 1, false)]);
view.cursor = 0;
assert!(
view.cursor_commit().is_none(),
"a worktree line has no commit to review"
);
}
#[test]
fn the_bracket_pair_steps_commit_blocks_and_b_toggles_the_column() {
let (_fixture, mut app) = app();
app.install_file(
view(
"one\ntwo\nthree\n",
vec![span(1, 2, true), span(3, 1, true)],
),
None,
);
app.handle(key(']'));
assert_eq!(app.file.as_ref().expect("view").cursor, 2);
app.handle(key('['));
assert_eq!(app.file.as_ref().expect("view").cursor, 0);
app.handle(key('b'));
assert!(!app.file.as_ref().expect("view").show_blame);
}
#[test]
fn a_load_the_user_navigated_away_from_never_lands() {
let (_fixture, mut app) = app();
app.open_working_tree_file("src/lib.rs");
app.handle(key('B'));
let stale = app.pending_file.as_ref().expect("a queued file").token;
app.handle(key('q'));
let flow = app.on_file_loaded(Ok(view("one\n", vec![span(1, 1, true)])), None, stale);
assert_eq!(flow, Flow::Idle, "a stale load draws nothing");
assert!(app.file.is_none());
assert_eq!(
app.screen(),
Screen::Status,
"the abandoned file view never comes back"
);
}
#[test]
fn the_newest_request_wins_when_two_loads_are_in_flight() {
let (_fixture, mut app) = app();
app.open_file("a.txt", None, false);
let first = app.pending_file.as_ref().expect("a queued file").token;
app.open_file("b.txt", None, false);
let second = app.pending_file.as_ref().expect("a queued file").token;
app.on_file_loaded(Ok(view("second\n", vec![span(1, 1, true)])), None, second);
app.on_file_loaded(Ok(view("first\n", vec![span(1, 1, true)])), None, first);
assert_eq!(
app.file.as_ref().expect("view").lines,
vec!["second"],
"the earlier load cannot overwrite the later one"
);
}
#[test]
fn opening_a_file_at_a_line_seats_the_cursor_there_and_clamps() {
let (_fixture, mut app) = app();
app.install_file(view("one\ntwo\n", vec![span(1, 2, true)]), Some(2));
assert_eq!(app.file.as_ref().expect("view").cursor, 1);
app.install_file(view("one\ntwo\n", vec![span(1, 2, true)]), Some(99));
assert_eq!(
app.file.as_ref().expect("view").cursor,
1,
"a line past the end lands on the last one"
);
}
}