feat: add local and remote repository diff views

This commit is contained in:
2026-10-10 23:01:34 +02:00
parent f6da7922a1
commit 81a2bf9a02
15 changed files with 4947 additions and 5 deletions
+163
View File
@@ -1,4 +1,5 @@
use crate::clipboard::{ClipboardMode, SystemClipboard};
use crate::diff::{DiffState, Operation, Outcome, Phase};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition};
use serde::Serialize;
@@ -553,6 +554,7 @@ core_actions![
"lsp.diagnostics",
),
("core.save", Action::Save, &[Mode::Normal], "w"),
("core.diff", Action::Diff, &[Mode::Normal], "diff"),
("core.quit", Action::Quit, &[Mode::Normal], "q"),
(
"core.force_quit",
@@ -1061,6 +1063,9 @@ pub struct Editor {
file_picker_view: FileView,
file_explorer_view: FileView,
count: Option<usize>,
diff: Option<DiffState>,
next_diff_request: u64,
outstanding_diff_request: Option<u64>,
}
impl Editor {
@@ -1128,6 +1133,9 @@ impl Editor {
file_picker_view: FileView::Tree,
file_explorer_view: FileView::Tree,
count: None,
diff: None,
next_diff_request: 0,
outstanding_diff_request: None,
};
editor.refresh_selection_navigation_hint();
editor
@@ -1228,6 +1236,77 @@ impl Editor {
&self.project_root
}
pub fn diff(&self) -> Option<&DiffState> {
self.diff.as_ref()
}
pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> {
let operation = self.diff.as_mut()?.take_request()?;
self.next_diff_request = self
.next_diff_request
.checked_add(1)
.expect("diff request IDs exhausted");
let id = self.next_diff_request;
self.outstanding_diff_request = Some(id);
Some((id, operation))
}
pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
if self.outstanding_diff_request != Some(id) {
return false;
}
self.outstanding_diff_request = None;
let Some(diff) = &mut self.diff else {
return false;
};
diff.apply_result(result);
true
}
fn open_diff(&mut self) {
self.count = None;
self.clear_leader();
self.palette = None;
self.hover = None;
self.startup_diagnostic = None;
self.blur_file_picker();
if self.active.mode == Mode::Command {
self.command_line.clear();
self.active.mode = Mode::Normal;
}
self.outstanding_diff_request = None;
self.diff = Some(DiffState::new());
}
fn handle_diff(&mut self, input: Input) {
// Escape and Tab always retain their modal close/back/focus semantics.
if !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
match self.selection_bindings.get(&input) {
Some(Binding::Action(Action::Down)) => {
self.diff.as_mut().expect("active diff").navigate(true);
return;
}
Some(Binding::Action(Action::Up)) => {
self.diff.as_mut().expect("active diff").navigate(false);
return;
}
Some(Binding::Disabled) => return,
_ => {}
}
}
let back = input == Input::Escape
&& self
.diff
.as_ref()
.is_some_and(|diff| diff.phase == Phase::After);
if self.diff.as_mut().expect("active diff").handle(input) {
self.diff = None;
self.outstanding_diff_request = None;
} else if back {
self.outstanding_diff_request = None;
}
}
fn buffer(&self, id: usize) -> Option<&Buffer> {
if id == self.active.id {
Some(&self.active)
@@ -2857,6 +2936,10 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch),
input => input,
};
if self.diff.is_some() {
self.handle_diff(input);
return None;
}
if self.completion.is_some() {
let request = matches!(
self.bindings
@@ -3140,6 +3223,7 @@ impl Editor {
| Action::BufferPrevious
| Action::BufferDelete
| Action::BufferForceDelete
| Action::Diff
| Action::LspDefinition
| Action::LspHover
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
@@ -3653,6 +3737,9 @@ impl Editor {
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
if self.diff.is_some() {
return None;
}
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
let (name, args) = command
.split_once(char::is_whitespace)
@@ -3694,6 +3781,14 @@ impl Editor {
}
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
if entry.action == Action::Diff {
if args.is_empty() {
self.open_diff();
} else {
self.set_message("The diff command takes no arguments");
}
return None;
}
self.prepare_command();
let action = entry.action;
let name = entry.ex.expect("Ex dispatcher requires an alias");
@@ -3775,6 +3870,13 @@ impl Editor {
/// to the primary caret: Insert runs once there, Replace replaces the buffer.
/// Rejected responses and message-only effects retain the complete set.
pub fn apply(&mut self, effects: Vec<Effect>) -> Result<(), String> {
if self.diff.is_some()
&& effects
.iter()
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
{
return Err("Diff view is read-only".into());
}
let mut total = 0usize;
for effect in &effects {
let text = match effect {
@@ -6274,6 +6376,67 @@ mod tests {
}
}
#[test]
fn diff_preserves_parked_buffers_visual_carets_and_insert_history() {
let directory = Directory::new();
let a = directory.path("a.txt");
let b = directory.path("b.txt");
fs::write(&a, "alpha").unwrap();
fs::write(&b, "beta").unwrap();
let mut editor = Editor::open(Some(a), vec![]).unwrap();
type_text(&mut editor, "iunsaved");
editor.handle(Input::Escape);
editor.execute(&format!("edit {}", b.display()));
type_text(&mut editor, "iother");
editor.handle(Input::Escape);
type_text(&mut editor, "vl");
let carets = editor.carets();
let selections = editor.selections();
let documents = editor
.documents()
.map(|document| (document.id, document.revision, document.lines.to_vec()))
.collect::<Vec<_>>();
editor.execute("diff");
for input in [
Input::Paste("bad".into()),
Input::Delete,
Input::Char('d'),
Input::Enter,
] {
editor.handle(input);
}
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.selections(), selections);
assert_eq!(
editor
.documents()
.map(|document| (document.id, document.revision, document.lines.to_vec()))
.collect::<Vec<_>>(),
documents
);
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "beta");
editor.execute("buffer 1");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "alpha");
editor.handle(Input::Char('i'));
type_text(&mut editor, "start");
let caret = editor.cursor();
editor.execute("diff");
editor.handle(Input::Paste("ignored".into()));
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.cursor(), caret);
type_text(&mut editor, "finish");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "alpha");
}
#[test]
fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() {
let mut editor = editor();