feat: add local and remote repository diff views
This commit is contained in:
+1065
File diff suppressed because it is too large
Load Diff
+163
@@ -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();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
pub mod clipboard;
|
||||
pub mod diff;
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
pub mod lsp;
|
||||
mod lsp_config;
|
||||
mod markdown;
|
||||
pub mod plugin;
|
||||
pub mod repository;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
mod snippet;
|
||||
|
||||
@@ -40,6 +40,9 @@ Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
||||
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
||||
Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout means disk, not unsaved buffers
|
||||
Tab/Shift+Tab categories/focus; type to search IDs/refs; arrows navigate/scroll; Esc back/close
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
|
||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.
|
||||
|
||||
+1393
File diff suppressed because it is too large
Load Diff
+245
-1
@@ -2,18 +2,125 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
mpsc::{self, Receiver, SyncSender},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diff::{Operation as DiffOperation, Outcome as DiffOutcome},
|
||||
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||
lsp::Lsp,
|
||||
plugin::{
|
||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||
TextPosition, UpdateEffect,
|
||||
},
|
||||
repository::Repository,
|
||||
ruby::{Config, RubyPlugin, load_config},
|
||||
};
|
||||
|
||||
type DiffResult = (u64, Result<DiffOutcome, String>);
|
||||
|
||||
struct DiffWorker {
|
||||
requests: Option<SyncSender<(u64, DiffOperation)>>,
|
||||
results: Option<Receiver<DiffResult>>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
cancelled_request: Arc<AtomicU64>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
pending: Option<u64>,
|
||||
}
|
||||
|
||||
impl DiffWorker {
|
||||
fn start(root: PathBuf) -> Result<Self, String> {
|
||||
let (requests, incoming) = mpsc::sync_channel::<(u64, DiffOperation)>(1);
|
||||
let (outgoing, results) = mpsc::sync_channel(1);
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let cancelled_request = Arc::new(AtomicU64::new(0));
|
||||
let cancelled_id = Arc::clone(&cancelled_request);
|
||||
let stop = Arc::clone(&stopped);
|
||||
let cancellation = Arc::clone(&cancelled);
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-diff".into())
|
||||
.spawn(move || {
|
||||
let mut repository = None;
|
||||
while let Ok((id, operation)) = incoming.recv() {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
cancellation.store(false, Ordering::Release);
|
||||
if stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if cancelled_id.load(Ordering::Acquire) == id {
|
||||
continue;
|
||||
}
|
||||
let result = (|| {
|
||||
if repository.is_none() {
|
||||
repository = Some(Repository::open(&root, Arc::clone(&cancellation))?);
|
||||
}
|
||||
let repository = repository.as_mut().expect("repository opened");
|
||||
match operation {
|
||||
DiffOperation::Discover => {
|
||||
repository.catalog().map(DiffOutcome::Catalog)
|
||||
}
|
||||
DiffOperation::FetchRemote(remote) => {
|
||||
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
||||
}
|
||||
DiffOperation::Compare { before, after } => repository
|
||||
.compare(&before, &after)
|
||||
.map(DiffOutcome::Comparison),
|
||||
}
|
||||
})();
|
||||
if stop.load(Ordering::Acquire) || outgoing.send((id, result)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|error| format!("Cannot start diff worker: {error}"))?;
|
||||
Ok(Self {
|
||||
requests: Some(requests),
|
||||
results: Some(results),
|
||||
cancelled,
|
||||
stopped,
|
||||
cancelled_request,
|
||||
thread: Some(thread),
|
||||
pending: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> {
|
||||
self.requests
|
||||
.as_ref()
|
||||
.expect("live diff worker has a sender")
|
||||
.try_send((id, operation))
|
||||
.map_err(|error| match error {
|
||||
mpsc::TrySendError::Full(_) => "Diff worker is already busy".to_owned(),
|
||||
mpsc::TrySendError::Disconnected(_) => {
|
||||
"Diff worker stopped unexpectedly".to_owned()
|
||||
}
|
||||
})?;
|
||||
self.pending = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DiffWorker {
|
||||
fn drop(&mut self) {
|
||||
self.stopped.store(true, Ordering::Release);
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
self.requests.take();
|
||||
self.results.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackedDocument {
|
||||
snapshot: DocumentSnapshot,
|
||||
save_revision: u64,
|
||||
@@ -45,6 +152,7 @@ pub struct EditorSession {
|
||||
pending_hover: bool,
|
||||
pending_completion: bool,
|
||||
hover_owner: Option<String>,
|
||||
diff_worker: Option<DiffWorker>,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
@@ -135,6 +243,7 @@ impl EditorSession {
|
||||
pending_hover: false,
|
||||
pending_completion: false,
|
||||
hover_owner: None,
|
||||
diff_worker: None,
|
||||
};
|
||||
session.synchronize_documents();
|
||||
Ok(session)
|
||||
@@ -152,6 +261,7 @@ impl EditorSession {
|
||||
self.dispatch(invocation);
|
||||
self.synchronize_documents();
|
||||
}
|
||||
self.start_diff_request();
|
||||
}
|
||||
|
||||
pub fn execute(&mut self, command: &str) {
|
||||
@@ -162,6 +272,82 @@ impl EditorSession {
|
||||
self.dispatch(invocation);
|
||||
self.synchronize_documents();
|
||||
}
|
||||
self.start_diff_request();
|
||||
}
|
||||
|
||||
fn start_diff_request(&mut self) {
|
||||
if self.editor.diff().is_none() {
|
||||
self.diff_worker = None;
|
||||
return;
|
||||
}
|
||||
if self.editor.diff().is_some_and(|diff| !diff.loading)
|
||||
&& let Some(worker) = &mut self.diff_worker
|
||||
&& let Some(id) = worker.pending.take()
|
||||
{
|
||||
worker.cancelled_request.store(id, Ordering::Release);
|
||||
worker.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
let Some((id, operation)) = self.editor.take_diff_request() else {
|
||||
return;
|
||||
};
|
||||
if self.diff_worker.is_none() {
|
||||
let root = self
|
||||
.editor
|
||||
.path()
|
||||
.and_then(Path::parent)
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| self.editor.project_root())
|
||||
.to_path_buf();
|
||||
match DiffWorker::start(root) {
|
||||
Ok(worker) => self.diff_worker = Some(worker),
|
||||
Err(error) => {
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(error) = self
|
||||
.diff_worker
|
||||
.as_mut()
|
||||
.expect("diff worker started")
|
||||
.request(id, operation)
|
||||
{
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
self.diff_worker = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_diff(&mut self) -> bool {
|
||||
self.start_diff_request();
|
||||
let mut changed = false;
|
||||
while let Some(worker) = self.diff_worker.as_ref() {
|
||||
let result = worker
|
||||
.results
|
||||
.as_ref()
|
||||
.expect("live diff worker has results")
|
||||
.try_recv();
|
||||
match result {
|
||||
Ok((id, result)) => {
|
||||
if let Some(worker) = &mut self.diff_worker
|
||||
&& worker.pending == Some(id)
|
||||
{
|
||||
worker.pending = None;
|
||||
}
|
||||
changed |= self.editor.apply_diff_result(id, result);
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
if let Some(id) = self.diff_worker.as_ref().and_then(|worker| worker.pending) {
|
||||
changed |= self
|
||||
.editor
|
||||
.apply_diff_result(id, Err("Diff worker stopped unexpectedly".into()));
|
||||
}
|
||||
self.diff_worker = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, invocation: Invocation) {
|
||||
@@ -368,6 +554,9 @@ impl EditorSession {
|
||||
request_id: Option<u64>,
|
||||
source: InvocationSource,
|
||||
) -> bool {
|
||||
if self.editor.diff().is_some() {
|
||||
return false;
|
||||
}
|
||||
self.editor.buffer_id() == source.buffer_id
|
||||
&& self.source_is_current(source)
|
||||
&& (owner != "core"
|
||||
@@ -617,7 +806,10 @@ impl EditorSession {
|
||||
let Some(source) = source else {
|
||||
return Err("plugin response has an unknown invocation ID".into());
|
||||
};
|
||||
if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id {
|
||||
if !self.source_is_current(source)
|
||||
|| self.editor.buffer_id() != source.buffer_id
|
||||
|| self.editor.diff().is_some()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
if effects
|
||||
@@ -641,6 +833,7 @@ impl EditorSession {
|
||||
/// Drains ready plugin work without waiting for command or server responses.
|
||||
pub fn poll(&mut self) -> bool {
|
||||
let mut changed = self.synchronize_documents();
|
||||
changed |= self.poll_diff();
|
||||
for update in self.lsp.poll() {
|
||||
match self.apply_update("core", update) {
|
||||
Ok(applied) => changed |= applied,
|
||||
@@ -3076,4 +3269,55 @@ end
|
||||
assert_eq!(invokes[0]["column"], 2);
|
||||
assert_eq!(invokes[0]["revision"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_plugin_edits_do_not_modify_diff_buffers_or_disable_the_plugin() {
|
||||
let fixture = Fixture::new();
|
||||
let source = fixture.file("buffer.txt", "original");
|
||||
let started = fixture.0.join("started");
|
||||
let release = fixture.0.join("release");
|
||||
fixture.file(
|
||||
"delayed.rb",
|
||||
r#"Vamp.plugin "delayed", requires: ["text"], async: true
|
||||
Vamp.command "slow" do |editor, _|
|
||||
File.write(Vamp.options.fetch("started"), "ready")
|
||||
sleep 0.001 until File.exist?(Vamp.options.fetch("release"))
|
||||
editor.insert "late edit"
|
||||
end
|
||||
Vamp.command "ping" do |editor, _|
|
||||
editor.message "still alive"
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
&format!(
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin 'delayed.rb', options: {{started: {}, release: {}}}\nend\n",
|
||||
serde_json::to_string(&started.to_string_lossy()).unwrap(),
|
||||
serde_json::to_string(&release.to_string_lossy()).unwrap(),
|
||||
),
|
||||
);
|
||||
let mut session =
|
||||
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
session.execute("slow");
|
||||
drain_until(&mut session, |_| started.exists());
|
||||
session.execute("diff");
|
||||
assert!(session.editor().diff().is_some());
|
||||
fs::write(&release, "release").unwrap();
|
||||
// Wait for transport consumption, then assert the consumer-visible result.
|
||||
drain_until(&mut session, |session| {
|
||||
session
|
||||
.requests
|
||||
.get("delayed")
|
||||
.is_none_or(|requests| requests.pending.is_empty())
|
||||
});
|
||||
assert_eq!(session.editor().context().text, "original");
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), "original");
|
||||
session.handle(Input::Escape);
|
||||
session.execute("ping");
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().message() == "still alive"
|
||||
});
|
||||
assert_eq!(session.editor().context().text, "original");
|
||||
}
|
||||
}
|
||||
|
||||
+870
-2
@@ -25,13 +25,14 @@ use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Layout, Position, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{
|
||||
diff::{ChangedFile, DiffState, FileStatus, Focus, Phase, RowKind},
|
||||
editor::{
|
||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
||||
},
|
||||
@@ -414,8 +415,18 @@ fn draw(
|
||||
) {
|
||||
panels.begin_frame();
|
||||
document.geometry = None;
|
||||
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
||||
let area = frame.area();
|
||||
if let Some(diff) = editor.diff() {
|
||||
draw_diff(
|
||||
frame,
|
||||
diff,
|
||||
area,
|
||||
editor.selection_navigation_hint(),
|
||||
panels,
|
||||
);
|
||||
return;
|
||||
}
|
||||
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -870,6 +881,461 @@ fn draw(
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffGeometry {
|
||||
regions: [(Rect, Focus); 3],
|
||||
}
|
||||
|
||||
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
|
||||
if area.width < 4 || area.height < 4 {
|
||||
return area;
|
||||
}
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(if focused {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}))
|
||||
.title(title);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
inner
|
||||
}
|
||||
|
||||
fn draw_diff(
|
||||
frame: &mut Frame,
|
||||
diff: &DiffState,
|
||||
area: Rect,
|
||||
navigation_hint: &str,
|
||||
panels: &mut WheelPanels,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
frame.render_widget(Clear, area);
|
||||
if diff.phase != Phase::View {
|
||||
let panel = floating_panel(area);
|
||||
let stage = if diff.phase == Phase::Before {
|
||||
"Before"
|
||||
} else {
|
||||
"After"
|
||||
};
|
||||
let title = format!(" Diff · Choose {stage} ");
|
||||
let content = diff_panel(frame, panel, &title, true);
|
||||
let notice = diff.error.as_deref().or_else(|| {
|
||||
if diff.loading {
|
||||
Some(if diff.after.is_some() {
|
||||
"Loading comparison…"
|
||||
} else {
|
||||
"Loading repository…"
|
||||
})
|
||||
} else {
|
||||
diff.catalog
|
||||
.as_ref()
|
||||
.and_then(|catalog| catalog.notice.as_deref())
|
||||
}
|
||||
});
|
||||
let [
|
||||
stage_area,
|
||||
tabs_area,
|
||||
query_area,
|
||||
notice_area,
|
||||
pinned_area,
|
||||
results,
|
||||
help,
|
||||
] = Layout::vertical([
|
||||
Constraint::Length(u16::from(content.height >= 5)),
|
||||
Constraint::Length(u16::from(content.height >= 4)),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(u16::from(notice.is_some() && content.height >= 3)),
|
||||
Constraint::Length(u16::from(content.height >= 2)),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(content.height >= 7)),
|
||||
])
|
||||
.areas(content);
|
||||
let before = diff
|
||||
.before
|
||||
.as_ref()
|
||||
.map_or("not selected", |revision| revision.label.as_str());
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("Choose {stage} · Before: {}", display_text(before))),
|
||||
stage_area,
|
||||
);
|
||||
if let Some(catalog) = &diff.catalog {
|
||||
let tabs: Vec<Span> = catalog
|
||||
.kind
|
||||
.categories()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, category)| {
|
||||
Span::styled(
|
||||
format!(" {} ", category.label()),
|
||||
if index == diff.category {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(Line::from(tabs)), tabs_area);
|
||||
}
|
||||
let query = format!("> {}", display_text(&diff.query));
|
||||
let width = usize::from(query_area.width);
|
||||
let query_width = display_column(&query, usize::MAX);
|
||||
let offset = query_width.saturating_sub(width.saturating_sub(1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&query, offset, width))
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
query_area,
|
||||
);
|
||||
if query_area.width > 0 && query_area.height > 0 {
|
||||
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(notice)).style(Style::default().fg(
|
||||
if diff.error.is_some() {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::Yellow
|
||||
},
|
||||
)),
|
||||
notice_area,
|
||||
);
|
||||
}
|
||||
let mut entries = diff.entries();
|
||||
if let Some(working) = entries.next() {
|
||||
let label = format!(
|
||||
"{} {} · pinned",
|
||||
if diff.selected == 0 { ">" } else { " " },
|
||||
display_text(&working.label)
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style(
|
||||
if diff.selected == 0 {
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
},
|
||||
),
|
||||
pinned_area,
|
||||
);
|
||||
}
|
||||
let row_height = if results.width >= 50 && results.height >= 4 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let count = usize::from(results.height) / row_height;
|
||||
let first = diff.selected.saturating_sub(count.saturating_sub(1)).max(1);
|
||||
let mut lines = Vec::with_capacity(count * row_height);
|
||||
for (index, revision) in entries
|
||||
.enumerate()
|
||||
.map(|(index, revision)| (index + 1, revision))
|
||||
.skip(first - 1)
|
||||
.take(count)
|
||||
{
|
||||
let style = if index == diff.selected {
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let label = format!(
|
||||
"{} {}",
|
||||
if index == diff.selected { ">" } else { " " },
|
||||
display_text(&revision.label)
|
||||
);
|
||||
let width = usize::from(results.width);
|
||||
if row_height == 2 {
|
||||
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
||||
lines.push(Line::styled(
|
||||
visible_line(
|
||||
&format!(" {}", display_text(&revision.description)),
|
||||
0,
|
||||
width,
|
||||
),
|
||||
style.fg(Color::DarkGray),
|
||||
));
|
||||
} else {
|
||||
lines.push(Line::styled(
|
||||
visible_line(
|
||||
&format!("{label} · {}", display_text(&revision.description)),
|
||||
0,
|
||||
width,
|
||||
),
|
||||
style,
|
||||
));
|
||||
}
|
||||
}
|
||||
if lines.is_empty() && !diff.loading {
|
||||
frame.render_widget(Paragraph::new("No matching revisions"), results);
|
||||
} else {
|
||||
frame.render_widget(Paragraph::new(lines), results);
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{navigation_hint} · Tab categories · type search · Enter select · Esc back/cancel"
|
||||
))
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help,
|
||||
);
|
||||
panels.diff = Some(DiffGeometry {
|
||||
regions: [
|
||||
(panel, Focus::Files),
|
||||
(Rect::default(), Focus::Before),
|
||||
(Rect::default(), Focus::After),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let comparison = diff.comparison.as_ref();
|
||||
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
|
||||
let notice = diff.error.as_deref().or_else(|| {
|
||||
if diff.loading {
|
||||
Some("Loading comparison…")
|
||||
} else {
|
||||
selected.and_then(|file| file.notice.as_deref())
|
||||
}
|
||||
});
|
||||
let [header, notice_area, workspace, help] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(u16::from(notice.is_some() && area.height >= 3)),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(area.height >= 3)),
|
||||
])
|
||||
.areas(area);
|
||||
let (before, after) = comparison.map_or(("", ""), |comparison| {
|
||||
(comparison.before.as_str(), comparison.after.as_str())
|
||||
});
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Diff · {} → {} · {:?} focus · read-only",
|
||||
display_text(before),
|
||||
display_text(after),
|
||||
diff.focus
|
||||
))
|
||||
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||
header,
|
||||
);
|
||||
if let Some(notice) = notice {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(notice)).style(Style::default().fg(
|
||||
if diff.error.is_some() {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::Yellow
|
||||
},
|
||||
)),
|
||||
notice_area,
|
||||
);
|
||||
}
|
||||
let navigator_width = if workspace.width >= 40 {
|
||||
(workspace.width / 4).min(32)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let [navigator, panes] =
|
||||
Layout::horizontal([Constraint::Length(navigator_width), Constraint::Fill(1)])
|
||||
.areas(workspace);
|
||||
let [before_area, after_area] =
|
||||
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes);
|
||||
let navigator_inner = diff_panel(
|
||||
frame,
|
||||
navigator,
|
||||
" Changed files ",
|
||||
diff.focus == Focus::Files,
|
||||
);
|
||||
if let Some(comparison) = comparison
|
||||
&& navigator_inner.width > 0
|
||||
&& navigator_inner.height > 0
|
||||
{
|
||||
let height = usize::from(navigator_inner.height);
|
||||
let first = diff.file.saturating_sub(height.saturating_sub(1));
|
||||
let lines: Vec<Line> = comparison
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(height)
|
||||
.map(|(index, file)| {
|
||||
let label = format!(
|
||||
"{} {} {}",
|
||||
if index == diff.file { ">" } else { " " },
|
||||
diff_status(file.status),
|
||||
display_text(&file.path.to_string_lossy())
|
||||
);
|
||||
let style = if index == diff.file {
|
||||
Style::default()
|
||||
.bg(if diff.focus == Focus::Files {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
})
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::styled(
|
||||
visible_line(&label, 0, usize::from(navigator_inner.width)),
|
||||
style,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), navigator_inner);
|
||||
}
|
||||
if let Some(file) = selected {
|
||||
draw_diff_pane(frame, diff, file, before_area, true);
|
||||
draw_diff_pane(frame, diff, file, after_area, false);
|
||||
} else {
|
||||
frame.render_widget(
|
||||
Paragraph::new(if diff.loading {
|
||||
"Loading comparison…"
|
||||
} else {
|
||||
"No changed files"
|
||||
}),
|
||||
panes,
|
||||
);
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Tab focus · ↑/↓ scroll/files · ←/→ horizontal · Home/End · Esc close · read-only",
|
||||
)
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help,
|
||||
);
|
||||
panels.diff = Some(DiffGeometry {
|
||||
regions: [
|
||||
(navigator, Focus::Files),
|
||||
(before_area, Focus::Before),
|
||||
(after_area, Focus::After),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
fn diff_status(status: FileStatus) -> &'static str {
|
||||
match status {
|
||||
FileStatus::Added => "A",
|
||||
FileStatus::Removed => "D",
|
||||
FileStatus::Modified => "M",
|
||||
FileStatus::Renamed => "R",
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_diff_pane(
|
||||
frame: &mut Frame,
|
||||
diff: &DiffState,
|
||||
file: &ChangedFile,
|
||||
area: Rect,
|
||||
before: bool,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let label = if before { "Before" } else { "After" };
|
||||
let path = if before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
};
|
||||
let title = format!(
|
||||
" {label} · {} · {} ",
|
||||
diff_status(file.status),
|
||||
display_text(&path.to_string_lossy())
|
||||
);
|
||||
let focus = if before { Focus::Before } else { Focus::After };
|
||||
let content = diff_panel(frame, area, &title, diff.focus == focus);
|
||||
if diff.rows.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
file.notice
|
||||
.as_deref()
|
||||
.map(display_text)
|
||||
.unwrap_or_else(|| "Empty file".into()),
|
||||
),
|
||||
content,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let source = if before { &file.before } else { &file.after };
|
||||
let left = if before {
|
||||
diff.left_before
|
||||
} else {
|
||||
diff.left_after
|
||||
};
|
||||
let digits = source.len().max(1).ilog10() as usize + 1;
|
||||
let gutter = if usize::from(content.width) > digits + 2 {
|
||||
digits + 2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let width = usize::from(content.width).saturating_sub(gutter);
|
||||
let lines: Vec<Line> = diff
|
||||
.rows
|
||||
.iter()
|
||||
.skip(diff.top)
|
||||
.take(usize::from(content.height))
|
||||
.map(|row| {
|
||||
let index = if before { row.before } else { row.after };
|
||||
let changed = matches!(row.kind, RowKind::Changed)
|
||||
|| (before && row.kind == RowKind::Removed)
|
||||
|| (!before && row.kind == RowKind::Added);
|
||||
let color = if changed {
|
||||
if before { Color::Red } else { Color::Green }
|
||||
} else {
|
||||
Color::Reset
|
||||
};
|
||||
let Some(index) = index else {
|
||||
return Line::from("");
|
||||
};
|
||||
let Some(text) = source.get(index) else {
|
||||
return Line::from("");
|
||||
};
|
||||
let text = if text.chars().any(|ch| ch.is_control() && ch != '\t') {
|
||||
Cow::Owned(
|
||||
text.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_control() && ch != '\t' {
|
||||
'�'
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect::<String>(),
|
||||
)
|
||||
} else {
|
||||
Cow::Borrowed(text.as_str())
|
||||
};
|
||||
let mut spans = Vec::with_capacity(2);
|
||||
if gutter > 0 {
|
||||
let marker = if changed {
|
||||
if before { '-' } else { '+' }
|
||||
} else {
|
||||
' '
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
format!("{:>digits$}{marker} ", index + 1),
|
||||
Style::default().fg(if changed { color } else { Color::DarkGray }),
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
visible_line(&text, left, width),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), content);
|
||||
}
|
||||
|
||||
fn floating_panel(area: Rect) -> Rect {
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
@@ -939,6 +1405,7 @@ struct WheelPanels {
|
||||
hover_syntax: SyntaxCache,
|
||||
files: Option<(Rect, usize, usize)>,
|
||||
manual_files: Option<FileWheelOffset>,
|
||||
diff: Option<DiffGeometry>,
|
||||
}
|
||||
|
||||
struct FileWheelOffset {
|
||||
@@ -973,6 +1440,7 @@ impl WheelPanels {
|
||||
self.hover = None;
|
||||
self.completion = None;
|
||||
self.files = None;
|
||||
self.diff = None;
|
||||
}
|
||||
|
||||
// Keyboard/paste may change the catalog without changing its length.
|
||||
@@ -1008,6 +1476,37 @@ impl WheelPanels {
|
||||
_ => return false,
|
||||
};
|
||||
let editor = session.editor();
|
||||
if editor.diff().is_some() {
|
||||
let position = Position::new(mouse.column, mouse.row);
|
||||
let focus = self.diff.as_ref().and_then(|geometry| {
|
||||
geometry
|
||||
.regions
|
||||
.iter()
|
||||
.find_map(|(area, focus)| area.contains(position).then_some(*focus))
|
||||
});
|
||||
if let Some(focus) = focus {
|
||||
if session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
{
|
||||
for _ in 0..3 {
|
||||
if session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.focus == focus)
|
||||
{
|
||||
break;
|
||||
}
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
}
|
||||
for _ in 0..3 {
|
||||
session.handle(if down { Input::Down } else { Input::Up });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
|
||||
return true;
|
||||
}
|
||||
@@ -1640,6 +2139,375 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn diff_catalog() -> crate::diff::Catalog {
|
||||
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
|
||||
let mut entries = vec![Revision {
|
||||
target: RevisionTarget::Working,
|
||||
label: "Working checkout".into(),
|
||||
description: "On-disk files".into(),
|
||||
category: Category::Commits,
|
||||
}];
|
||||
entries.extend((0..20).map(|index| Revision {
|
||||
target: RevisionTarget::Commit(format!("commit-{index:02}")),
|
||||
label: format!("commit-{index:02}"),
|
||||
description: if index == 19 {
|
||||
"needle description".into()
|
||||
} else {
|
||||
"ordinary change".into()
|
||||
},
|
||||
category: Category::Commits,
|
||||
}));
|
||||
entries.push(Revision {
|
||||
target: RevisionTarget::Commit("branch-tip".into()),
|
||||
label: "feature-branch".into(),
|
||||
description: "branch description".into(),
|
||||
category: Category::Branches,
|
||||
});
|
||||
Catalog {
|
||||
kind: RepoKind::Git,
|
||||
entries,
|
||||
notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
|
||||
let mut diff = DiffState::new();
|
||||
diff.take_request();
|
||||
diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||
diff.handle(Input::Enter);
|
||||
diff.handle(Input::Enter);
|
||||
diff.take_request();
|
||||
diff.apply_result(Ok(crate::diff::Outcome::Comparison(
|
||||
crate::diff::Comparison {
|
||||
before: "old revision".into(),
|
||||
after: "Working checkout".into(),
|
||||
files,
|
||||
},
|
||||
)));
|
||||
diff
|
||||
}
|
||||
|
||||
fn deletion_file() -> ChangedFile {
|
||||
ChangedFile {
|
||||
path: "deleted-lines.txt".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
before: ["keep", "gone", "tail"].map(String::from).to_vec(),
|
||||
after: ["keep", "tail"].map(String::from).to_vec(),
|
||||
hunks: vec![crate::diff::Hunk {
|
||||
before_start: 1,
|
||||
before_len: 1,
|
||||
after_start: 1,
|
||||
after_len: 0,
|
||||
}],
|
||||
notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||
diff: &DiffState,
|
||||
panels: &mut WheelPanels,
|
||||
) {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
panels.begin_frame();
|
||||
draw_diff(frame, diff, frame.area(), "↑/↓", panels);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
|
||||
let mut diff = DiffState::new();
|
||||
diff.take_request();
|
||||
diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
diff.handle(Input::End);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Choose Before"));
|
||||
assert!(text.contains("Working checkout · pinned"));
|
||||
assert!(text.contains("> commit-19"));
|
||||
assert!(!text.contains("commit-00"));
|
||||
for ch in "needle".chars() {
|
||||
diff.handle(Input::Char(ch));
|
||||
}
|
||||
diff.handle(Input::Down);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> needle"));
|
||||
assert!(text.contains("Working checkout · pinned"));
|
||||
assert!(text.contains("> commit-19"));
|
||||
diff.handle(Input::Enter);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Choose After · Before: commit-19"));
|
||||
diff.handle(Input::Escape);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("> needle"));
|
||||
assert!(screen_text(&terminal).contains("> commit-19"));
|
||||
diff.handle(Input::Ctrl('u'));
|
||||
diff.handle(Input::Char('\t'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("feature-branch"));
|
||||
assert!(!text.contains("ordinary change"));
|
||||
assert!(text.contains("Working checkout · pinned"));
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert!(
|
||||
buffer
|
||||
.content
|
||||
.iter()
|
||||
.any(|cell| cell.symbol() == "B" && cell.bg == Color::Cyan)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
|
||||
let mut diff = diff_comparison(vec![deletion_file()]);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let geometry = panels.diff.as_ref().unwrap();
|
||||
let before = geometry.regions[1].0;
|
||||
let after = geometry.regions[2].0;
|
||||
let buffer = terminal.backend().buffer();
|
||||
let y = before.y + 2;
|
||||
assert_eq!(buffer[(before.x + 4, y)].symbol(), "g");
|
||||
assert_eq!(buffer[(before.x + 4, y)].fg, Color::Red);
|
||||
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
|
||||
for x in after.x + 1..after.right() - 1 {
|
||||
assert_eq!(buffer[(x, y)].symbol(), " ");
|
||||
}
|
||||
assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3");
|
||||
assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2");
|
||||
assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t");
|
||||
assert_eq!(buffer[(after.x + 4, y + 1)].symbol(), "t");
|
||||
diff.handle(Input::Char('\t'));
|
||||
diff.handle(Input::Down);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g");
|
||||
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
|
||||
assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray);
|
||||
assert!(screen_text(&terminal).contains("Before focus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_added_rows_leave_before_blank_and_show_green_after_numbers() {
|
||||
let diff = diff_comparison(vec![ChangedFile {
|
||||
path: "added.txt".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Added,
|
||||
before: vec![],
|
||||
after: vec!["first addition".into(), "second addition".into()],
|
||||
hunks: vec![crate::diff::Hunk {
|
||||
before_start: 0,
|
||||
before_len: 0,
|
||||
after_start: 0,
|
||||
after_len: 2,
|
||||
}],
|
||||
notice: None,
|
||||
}]);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let geometry = panels.diff.as_ref().unwrap();
|
||||
let before = geometry.regions[1].0;
|
||||
let after = geometry.regions[2].0;
|
||||
let buffer = terminal.backend().buffer();
|
||||
for offset in 0..2 {
|
||||
let y = before.y + 1 + offset;
|
||||
for x in before.x + 1..before.right() - 1 {
|
||||
assert_eq!(buffer[(x, y)].symbol(), " ");
|
||||
}
|
||||
assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string());
|
||||
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
|
||||
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Green);
|
||||
}
|
||||
assert!(screen_text(&terminal).contains("> A added.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
|
||||
let added = ChangedFile {
|
||||
path: "new-name.txt".into(),
|
||||
old_path: Some("old-name.txt".into()),
|
||||
status: FileStatus::Renamed,
|
||||
before: vec!["\t界e\u{301}OLD".into()],
|
||||
after: vec!["\t界e\u{301}NEW".into()],
|
||||
hunks: vec![crate::diff::Hunk {
|
||||
before_start: 0,
|
||||
before_len: 1,
|
||||
after_start: 0,
|
||||
after_len: 1,
|
||||
}],
|
||||
notice: None,
|
||||
};
|
||||
let mut diff = diff_comparison(vec![deletion_file(), added]);
|
||||
diff.handle(Input::Down);
|
||||
diff.handle(Input::Char('\t'));
|
||||
for _ in 0..4 {
|
||||
diff.handle(Input::Right);
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> R new-name.txt"));
|
||||
assert!(text.contains("Before · R · old-name.txt"));
|
||||
assert!(text.contains("After · R · new-name.txt"));
|
||||
let geometry = panels.diff.as_ref().unwrap();
|
||||
let before = geometry.regions[1].0;
|
||||
let after = geometry.regions[2].0;
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "界");
|
||||
assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}");
|
||||
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
|
||||
assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界");
|
||||
assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Green);
|
||||
diff.handle(Input::Char('\t'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("After focus"));
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(after.x, after.y)].fg,
|
||||
Color::Cyan
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
let mut diff = DiffState::new();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Loading repository"));
|
||||
diff.take_request();
|
||||
diff.apply_result(Err("permission denied".into()));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("permission denied"));
|
||||
let empty = diff_comparison(vec![]);
|
||||
render_diff(&mut terminal, &empty, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("No changed files"));
|
||||
let binary = diff_comparison(vec![ChangedFile {
|
||||
path: "image.bin".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Added,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
hunks: vec![],
|
||||
notice: Some("Binary file; text comparison unavailable".into()),
|
||||
}]);
|
||||
render_diff(&mut terminal, &binary, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Binary file"));
|
||||
let blank = diff_comparison(vec![ChangedFile {
|
||||
path: "empty.txt".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Added,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
hunks: vec![],
|
||||
notice: None,
|
||||
}]);
|
||||
render_diff(&mut terminal, &blank, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Empty file"));
|
||||
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] {
|
||||
let mut tiny =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
for state in [&diff, &empty, &binary, &blank] {
|
||||
render_diff(&mut tiny, state, &mut panels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_diff_draw_path_is_modal_and_escape_restores_document_viewport() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = Editor::open(
|
||||
Some(directory.file("source.txt", "original\nsecond")),
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
let original = editor.lines().to_vec();
|
||||
let cursor = editor.cursor();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
editor.execute("diff");
|
||||
let (id, _) = editor.take_diff_request().unwrap();
|
||||
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Choose Before"));
|
||||
assert!(viewports.document.geometry.is_none());
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Enter);
|
||||
let (id, _) = editor.take_diff_request().unwrap();
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
before: "old revision".into(),
|
||||
after: "Working checkout".into(),
|
||||
files: vec![deletion_file()],
|
||||
})),
|
||||
);
|
||||
editor.handle(Input::Paste("must not edit".into()));
|
||||
editor.handle(Input::Char('i'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
|
||||
assert!(viewports.document.geometry.is_none());
|
||||
assert_eq!(editor.lines(), original);
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("original"));
|
||||
assert!(viewports.document.geometry.is_some());
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.file("rows.txt", &["original"; 60].join("\n"));
|
||||
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
let id = session.editor().buffer_id();
|
||||
let offset = viewports.offsets[&id];
|
||||
let cursor = session.editor().cursor();
|
||||
session.execute("diff");
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
assert!(viewports.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollDown, 0, 0)
|
||||
));
|
||||
assert!(viewports.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollDown, 60, 15)
|
||||
));
|
||||
assert_eq!(viewports.offsets[&id], offset);
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
assert!(viewports.document.geometry.is_none());
|
||||
session.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
assert!(viewports.document.geometry.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
|
||||
let mut completion = Completion {
|
||||
|
||||
Reference in New Issue
Block a user