use std::{ collections::BTreeMap, io, path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{self, Receiver, Sender}, }, thread::{self, JoinHandle}, time::{Duration, Instant}, }; use crate::{ blame::BlameWorker, diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome}, editor::{BlameSource, 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); #[derive(Clone, Copy)] struct PullRequestJob { page: u32, busy: bool, complete: bool, failed: bool, retry: u64, } struct PullRequestWorker { id: u64, requests: Vec>, threads: Vec>, cancelled: Arc, jobs: [PullRequestJob; 2], priority: usize, } impl PullRequestWorker { fn start( id: u64, root: &Path, remote: String, outgoing: Sender, ) -> Result { let cancelled = Arc::new(AtomicBool::new(false)); let fetches = std::array::from_fn(|index| { let root = root.to_path_buf(); let remote = remote.clone(); let cancelled = Arc::clone(&cancelled); let mut repository = None; move |page| { if repository.is_none() { repository = Some(Repository::open(&root, Arc::clone(&cancelled))?); } repository .as_mut() .expect("repository opened") .pull_request_page(&remote, index == 1, page) } }); Self::start_with(id, outgoing, cancelled, fetches) } fn start_with( id: u64, outgoing: Sender, cancelled: Arc, fetches: [impl FnMut(u32) -> Result + Send + 'static; 2], ) -> Result { let mut worker = Self { id, requests: Vec::with_capacity(2), threads: Vec::with_capacity(2), cancelled, jobs: [PullRequestJob { page: 1, busy: false, complete: false, failed: false, retry: 0, }; 2], priority: 0, }; for (index, mut fetch) in fetches.into_iter().enumerate() { let (requests, incoming) = mpsc::channel(); let outgoing = outgoing.clone(); let cancelled = Arc::clone(&worker.cancelled); let thread = thread::Builder::new() .name(format!("vamp-pull-requests-{index}")) .spawn(move || { while let Ok(page) = incoming.recv() { if cancelled.load(Ordering::Acquire) { break; } let result = fetch(page); if cancelled.load(Ordering::Acquire) || outgoing .send(( id, Ok(DiffOutcome::PullRequestPage { closed: index == 1, result, }), )) .is_err() { break; } } }) .map_err(|error| format!("Cannot start pull request worker: {error}"))?; worker.requests.push(requests); worker.threads.push(thread); } worker.schedule()?; Ok(worker) } fn update(&mut self, category: usize, retries: [u64; 2]) -> Result<(), String> { self.priority = usize::from(category == 1); for (job, retry) in self.jobs.iter_mut().zip(retries) { if job.retry != retry { job.retry = retry; job.failed = false; } } self.schedule() } fn complete(&mut self, closed: bool, result: &Result) { let job = &mut self.jobs[usize::from(closed)]; job.busy = false; match result { Ok(page) => { job.complete = !page.has_more; job.page += 1; } Err(_) => job.failed = true, } } fn schedule(&mut self) -> Result<(), String> { for index in [self.priority, 1 - self.priority] { let job = &mut self.jobs[index]; if !job.busy && !job.complete && !job.failed { self.requests[index] .send(job.page) .map_err(|_| "Pull request worker stopped unexpectedly")?; job.busy = true; } } Ok(()) } } impl Drop for PullRequestWorker { fn drop(&mut self) { self.cancelled.store(true, Ordering::Release); self.requests.clear(); for thread in self.threads.drain(..) { let _ = thread.join(); } } } struct DiffWorker { requests: Option>, results: Option>, cancelled: Arc, stopped: Arc, cancelled_request: Arc, thread: Option>, root: PathBuf, outgoing: Sender, pull_requests: Option, pending: Option, } impl DiffWorker { fn start(root: PathBuf) -> Result { let (requests, incoming) = mpsc::channel::<(u64, DiffOperation)>(); let (outgoing, results) = mpsc::channel(); let sender = outgoing.clone(); let worker_root = root.clone(); 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(&worker_root, Arc::clone(&cancellation))?); } let repository = repository.as_mut().expect("repository opened"); match operation { DiffOperation::Discover => { repository.catalog().map(DiffOutcome::Catalog) } DiffOperation::PullRequests(_) => { unreachable!("listing uses category workers") } DiffOperation::ResolvePullRequest(revision) => repository .resolve_pull_request(revision) .map(|(before, after)| DiffOutcome::ResolvedPullRequest { before, after, }), DiffOperation::ListRemote(remote) => { repository.list_remote(&remote).map(DiffOutcome::Catalog) } DiffOperation::FetchRemote(remote) => { repository.fetch_remote(&remote).map(DiffOutcome::Catalog) } DiffOperation::Compare { before, after, mode, buffers, } => repository .compare(id, &before, &after, mode, &buffers) .map(DiffOutcome::Comparison), DiffOperation::RefreshCheckout { comparison_id, follow_working, buffers, } => repository .refresh_checkout(comparison_id, id, follow_working, &buffers) .map(DiffOutcome::CheckoutRefresh), DiffOperation::LoadFile { comparison_id, file_index, } => repository.load_file(comparison_id, file_index).map(|file| { DiffOutcome::File { comparison_id, file_index, file, } }), } })(); 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), root, outgoing: sender, pull_requests: None, pending: None, }) } fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> { self.pull_requests = None; if let Some(previous) = self.pending.take() { self.cancelled_request.store(previous, Ordering::Release); self.cancelled.store(true, Ordering::Release); } if let DiffOperation::PullRequests(remote) = operation { self.pull_requests = Some(PullRequestWorker::start( id, &self.root, remote, self.outgoing.clone(), )?); self.pending = Some(id); return Ok(()); } self.requests .as_ref() .expect("live diff worker has a sender") .send((id, operation)) .map_err(|_| "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.pull_requests = None; 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, } #[derive(Clone, Copy)] struct InvocationSource { buffer_id: usize, pane_id: usize, revision: u64, } #[derive(Default)] struct AsyncRequests { pending: BTreeMap, latest: Option<(u64, InvocationSource)>, } struct GutterSource { buffer: usize, revision: u64, save_revision: u64, path: PathBuf, dirty: bool, contents: Arc, } /// Owns command eligibility and execution hosts together. Frontends can inspect /// the editor, but cannot independently mutate its plugin registry. pub struct EditorSession { editor: Editor, plugins: BTreeMap, documents: BTreeMap, requests: BTreeMap, lsp: Lsp, lsp_request_id: u64, lsp_source_cursor: Option, lsp_source_mode: Mode, lsp_source_view: Option<(usize, Option)>, pending_hover: bool, pending_completion: bool, hover_owner: Option, diff_workers: BTreeMap, blame_worker: Option, blame_source: Option, blame_lines: Option>>>, blame_request_id: u64, gutter_worker: Option, gutter_sources: Vec, gutter_request_id: u64, gutter_pending: bool, last_gutter_refresh: Option, picker_source: Option<(usize, PathBuf)>, battery: Option, battery_enabled: bool, last_file_refresh: Instant, last_diff_refresh: Instant, pub(crate) diff_follow_working_copy: bool, } impl EditorSession { /// File errors fail opening; rejected configuration leaves core editing /// available and presents a persistent startup diagnostic. pub fn open( file: Option, capabilities: Vec, config: Option<&Path>, ) -> io::Result { let mut editor = Editor::open(file, capabilities)?; let mut notices = Vec::new(); let config = match config { Some(path) => load_config(path).unwrap_or_else(|error| { editor.show_startup_diagnostic(format!( "{}\n\nConfiguration was rejected; using default settings and bindings.\n\n{error}", path.display() )); Config::default() }), None => Config::default(), }; if let Err(error) = editor.set_leader(&config.leader) { notices.push(format!("leader {:?}: {error}", config.leader)); } if let Err(error) = editor.set_file_explorer_side(&config.file_explorer_side) { notices.push(format!( "file explorer side {:?}: {error}", config.file_explorer_side )); } if let Err(error) = editor.set_file_picker_view(&config.file_picker_view) { notices.push(format!( "file picker view {:?}: {error}", config.file_picker_view )); } if let Err(error) = editor.set_file_explorer_view(&config.file_explorer_view) { notices.push(format!( "file explorer view {:?}: {error}", config.file_explorer_view )); } if let Err(error) = editor.set_clipboard_mode(&config.clipboard_mode) { notices.push(format!( "clipboard mode {:?}: {error}", config.clipboard_mode )); } editor.set_git_blame(config.git_blame); editor.set_gutter_style(config.git_gutter); editor.set_scroll_distance(config.scroll_distance); editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace); editor.set_whitespace_characters(config.whitespace_characters); editor.set_whitespace_scope(config.whitespace_scope); editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces); editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines); editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation); if let Err(error) = editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels) { notices.push(format!("chrome: {error}")); } editor.set_popup_config(config.popup); let mut plugins = BTreeMap::new(); for configured in config.plugins { let path = configured.path; match RubyPlugin::start(&path, &configured.options) { Ok((plugin, feature)) => { let name = feature.name.clone(); match editor.register(feature) { Ok(()) => { plugins.insert(name, plugin); } Err(error) => notices.push(format!("{}: {error}", path.display())), } } Err(error) => notices.push(format!("{}: {error}", path.display())), } } for binding in config.bindings { if let Err(error) = editor.bind(&binding.mode, &binding.key, binding.command) { notices.push(format!( "binding {} {:?}: {error}", binding.mode, binding.key )); } } if !notices.is_empty() { editor.set_message(notices.join(" | ")); } let mut session = Self { editor, plugins, documents: BTreeMap::new(), requests: BTreeMap::new(), lsp: Lsp::new(config.lsp), lsp_request_id: 0, lsp_source_cursor: None, lsp_source_mode: Mode::Normal, lsp_source_view: None, pending_hover: false, pending_completion: false, hover_owner: None, diff_workers: BTreeMap::new(), blame_worker: None, blame_source: None, blame_lines: None, blame_request_id: 0, gutter_worker: None, gutter_sources: Vec::new(), gutter_request_id: 0, gutter_pending: false, last_gutter_refresh: None, picker_source: None, battery: None, battery_enabled: false, last_file_refresh: Instant::now(), last_diff_refresh: Instant::now(), diff_follow_working_copy: config.diff_follow_working_copy, }; session.synchronize_documents(); session.poll_battery(); session.poll_gutter_at(Instant::now()); Ok(session) } pub fn editor(&self) -> &Editor { &self.editor } /// Rendering-only mutable access; document synchronization stays in session dispatch. pub(crate) fn editor_mut(&mut self) -> &mut Editor { &mut self.editor } pub(crate) fn set_diff_viewport( &mut self, comparison_id: u64, file: usize, top: usize, left_before: usize, left_after: usize, ) { self.editor .set_diff_viewport(comparison_id, file, top, left_before, left_after); } pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { self.editor.scroll_diff(focus, down); self.synchronize_documents(); } pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { self.editor.scroll_diff_horizontal(focus, right, width); self.synchronize_documents(); } pub fn handle(&mut self, input: Input) { if input == Input::Ctrl('r') && self .editor .normal_file_picker() .is_some_and(|picker| picker.focused) { self.last_gutter_refresh = None; } self.cancel_pending_popup_request(); let invocation = self.editor.handle(input); self.synchronize_documents(); if let Some(invocation) = invocation { self.dispatch(invocation); self.synchronize_documents(); } self.start_diff_request(); } pub fn execute(&mut self, command: &str) { self.cancel_pending_popup_request(); let invocation = self.editor.execute(command); self.synchronize_documents(); if let Some(invocation) = invocation { self.dispatch(invocation); self.synchronize_documents(); } self.start_diff_request(); } fn start_diff_request(&mut self) { self.diff_workers.retain(|tab, worker| { let Some(diff) = self.editor.tab_diff(*tab) else { return false; }; if let Some(listing) = &mut worker.pull_requests { if let Err(error) = listing.update(diff.category, diff.pull_request_retries) { if let Some(id) = worker.pending.take() { self.editor.apply_diff_result(id, Err(error)); } return false; } return true; } if !diff.loading && !diff.refresh_pending() && let Some(id) = worker.pending.take() { worker.cancelled_request.store(id, Ordering::Release); worker.cancelled.store(true, Ordering::Release); } true }); while let Some((tab, id, operation)) = self.editor.take_next_diff_request() { if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab) { match DiffWorker::start(self.editor.diff_root(tab)) { Ok(worker) => { entry.insert(worker); } Err(error) => { self.editor.apply_diff_result(id, Err(error)); continue; } } } if let Err(error) = self .diff_workers .get_mut(&tab) .expect("diff worker started") .request(id, operation) { self.editor.apply_diff_result(id, Err(error)); self.diff_workers.remove(&tab); } } } fn poll_diff(&mut self) -> bool { self.start_diff_request(); let mut changed = false; self.diff_workers.retain(|_, worker| { loop { let result = worker .results .as_ref() .expect("live diff worker has results") .try_recv(); match result { Ok((id, result)) => { if let Ok(DiffOutcome::PullRequestPage { closed, result }) = &result { if let Some(listing) = worker .pull_requests .as_mut() .filter(|listing| listing.id == id) { listing.complete(*closed, result); } } else if worker.pending == Some(id) { worker.pending = None; } changed |= self.editor.apply_diff_result(id, result); } Err(mpsc::TryRecvError::Empty) => { if let Some(listing) = &mut worker.pull_requests && let Err(error) = listing.schedule() { changed |= self.editor.apply_diff_result(listing.id, Err(error)); return false; } return true; } Err(mpsc::TryRecvError::Disconnected) => { if let Some(id) = worker.pending.take() { changed |= self.editor.apply_diff_result( id, Err("Diff worker stopped unexpectedly".into()), ); } return false; } } } }); changed } fn dispatch(&mut self, invocation: Invocation) { if invocation.plugin == "core" { if !self.editor.document_actions_available() || (invocation.command == "lsp.completion" && self.editor.mutation_blocked()) { return; } let result = match invocation.command.as_str() { "lsp.definition" | "lsp.hover" | "lsp.completion" => { if let Some(id) = self.lsp_request_id.checked_add(1) { self.lsp_request_id = id; let context = self.editor.lsp_context(); let source = InvocationSource { buffer_id: context.buffer_id, pane_id: self.editor.pane_id(), revision: context.revision, }; self.requests.entry("core".into()).or_default().latest = Some((id, source)); self.lsp_source_cursor = Some(self.editor.cursor()); self.lsp_source_mode = self.editor.mode(); self.lsp_source_view = Some(( self.editor.tab_id(), self.editor.diff().and_then(|diff| diff.comparison_id), )); self.pending_hover = invocation.command == "lsp.hover"; self.pending_completion = invocation.command == "lsp.completion"; match invocation.command.as_str() { "lsp.hover" => self.lsp.hover(&context, id), "lsp.completion" => self.lsp.completion(&context, id), _ => self.lsp.definition(&context, id), } } else { Err("LSP request IDs exhausted".into()) } } "lsp.diagnostics" => self.lsp.diagnostics(self.editor.buffer_id()), _ => unreachable!("session-dispatched core command"), }; if let Err(error) = result { self.cancel_pending_popup_request(); self.editor.set_message(format!("LSP: {error}")); } return; } let context = self.editor.context(); let read_only = self.editor.mutation_blocked(); let result = match self.plugins.get_mut(&invocation.plugin) { Some(plugin) if plugin.is_async() => { let requests = self.requests.entry(invocation.plugin.clone()).or_default(); plugin .queue_invoke(&invocation.command, &invocation.args, &context) .map(|id| { let source = InvocationSource { buffer_id: context.buffer_id, pane_id: self.editor.pane_id(), revision: context.revision, }; requests.pending.insert(id, source); requests.latest = Some((id, source)); }) } Some(plugin) => plugin .invoke(&invocation.command, &invocation.args, &context) .and_then(|effects| { if read_only && effects .iter() .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) { self.editor.set_message("Diff pane is read-only"); Ok(()) } else { self.editor.apply(effects) } }), None => Err("plugin host is unavailable".into()), }; if let Err(error) = result { self.retire(&invocation.plugin, &error); } } fn cancel_pending_popup_request(&mut self) { if self.pending_hover || self.pending_completion { if let Some(requests) = self.requests.get_mut("core") { requests.latest = None; } self.pending_hover = false; self.pending_completion = false; } } fn retire(&mut self, owner: &str, error: &str) { // Drop also stops a live host whose protocol-valid effects were rejected. drop(self.plugins.remove(owner)); self.requests.remove(owner); self.editor.disable_plugin(owner, error); if self.hover_owner.as_deref() == Some(owner) { self.editor.clear_hover(); self.hover_owner = None; } } fn synchronize_documents(&mut self) -> bool { let blame_changed = self.poll_blame(); // Leaving a request's source invalidates it permanently, even if history returns there. if let Some(requests) = self.requests.get_mut("core") && requests.latest.is_some_and(|(_, source)| { source.buffer_id != self.editor.buffer_id() || source.pane_id != self.editor.pane_id() || source.revision != self.editor.text_revision() || self.lsp_source_cursor != Some(self.editor.cursor()) || self.editor.mode() != self.lsp_source_mode || self.lsp_source_view != Some(( self.editor.tab_id(), self.editor.diff().and_then(|diff| diff.comparison_id), )) || !self.editor.document_actions_available() }) { requests.latest = None; self.lsp_source_cursor = None; self.lsp_source_view = None; self.pending_hover = false; self.pending_completion = false; } if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) { self.documents.clear(); return self.refresh_lsp_names() | blame_changed; } let mut events = Vec::new(); if !self.editor.should_quit() { for document in self.editor.interaction_documents() { let path = document.path.map(Path::to_string_lossy); let root = self .editor .interaction_document_root(document.id) .to_string_lossy(); let previous = self.documents.get(&document.id); let renamed = previous.is_some_and(|previous| { previous.snapshot.path.as_deref() != path.as_deref() || previous.snapshot.root.as_str() != root.as_ref() }); if renamed { events.push(DocumentEvent { event: DocumentEventKind::Close, document: previous.expect("renamed document existed").snapshot.clone(), }); } let changed = previous.is_none_or(|previous| { previous.snapshot.revision != document.revision || renamed }); if changed { let snapshot = DocumentSnapshot { id: document.id, revision: document.revision, path: path.map(|path| path.into_owned()), root: root.into_owned(), text: document.lines.join("\n"), }; events.push(DocumentEvent { event: if previous.is_none() || renamed { DocumentEventKind::Open } else { DocumentEventKind::Change }, document: snapshot.clone(), }); let save_revision = previous.map_or(0, |previous| previous.save_revision); self.documents.insert( document.id, TrackedDocument { snapshot, save_revision, }, ); } let tracked = self .documents .get_mut(&document.id) .expect("document tracked"); if tracked.save_revision != document.save_revision { events.push(DocumentEvent { event: DocumentEventKind::Save, document: tracked.snapshot.clone(), }); tracked.save_revision = document.save_revision; } } } let closed: Vec<_> = self .documents .keys() .filter(|id| self.editor.should_quit() || !self.editor.has_buffer(**id)) .copied() .collect(); for id in closed { let document = self.documents.remove(&id).expect("closed document existed"); events.push(DocumentEvent { event: DocumentEventKind::Close, document: document.snapshot, }); } let mut failures = Vec::new(); let mut changed = blame_changed; for event in &events { if let Err(error) = self.lsp.notify_document(event) { self.editor.set_message(format!("LSP: {error}")); changed = true; } } for (owner, plugin) in &mut self.plugins { if !plugin.is_async() { continue; } for event in &events { if let Err(error) = plugin.notify_document(event) { failures.push((owner.clone(), error)); break; } } } changed |= !failures.is_empty(); for (owner, error) in failures { self.retire(&owner, &error); } changed | self.refresh_lsp_names() } fn refresh_lsp_names(&mut self) -> bool { let buffer_id = self.editor.buffer_id(); if self.lsp.active_server_names(buffer_id).eq(self .editor .lsp_names() .iter() .map(String::as_str)) { return false; } self.editor.set_lsp_names( self.lsp .active_server_names(buffer_id) .map(str::to_owned) .collect(), ) } fn source_is_current(&self, source: InvocationSource) -> bool { self.editor .interaction_documents() .any(|document| document.id == source.buffer_id && document.revision == source.revision) } fn request_is_current( &self, owner: &str, request_id: Option, source: InvocationSource, ) -> bool { if !self.editor.document_actions_available() { return false; } self.editor.buffer_id() == source.buffer_id && self.source_is_current(source) && (owner != "core" || (self.lsp_source_cursor == Some(self.editor.cursor()) && self.editor.mode() == self.lsp_source_mode && self.lsp_source_view == Some(( self.editor.tab_id(), self.editor.diff().and_then(|diff| diff.comparison_id), )))) && self .requests .get(owner) .and_then(|requests| requests.latest) .is_some_and(|(id, latest)| { request_id == Some(id) && latest.buffer_id == source.buffer_id && latest.pane_id == self.editor.pane_id() && latest.revision == source.revision }) } fn hover_is_current( &self, owner: &str, request_id: Option, source: InvocationSource, ) -> bool { self.request_is_current(owner, request_id, source) && self.editor.mode() == Mode::Normal && self.editor.palette().is_none() && self .editor .file_picker() .is_none_or(|picker| !picker.focused) } fn completion_is_current(&self, request_id: Option, source: InvocationSource) -> bool { self.pending_completion && !self.editor.mutation_blocked() && self.request_is_current("core", request_id, source) && self.editor.mode() == Mode::Insert && self.editor.palette().is_none() && self .editor .file_picker() .is_none_or(|picker| !picker.focused) && self.editor.startup_diagnostic().is_none() } fn apply_update(&mut self, owner: &str, mut update: PluginUpdate) -> Result { if owner == "core" && let Some(id) = update.request_id && self .requests .get(owner) .and_then(|requests| requests.latest) .map(|latest| latest.0) != Some(id) { update .effects .retain(|effect| matches!(effect, UpdateEffect::Label { .. })); } for effect in &update.effects { if let UpdateEffect::Completion { buffer_id, revision, items, } = effect { if owner != "core" { return Err("Completion is a native LSP update only".into()); } let source = InvocationSource { buffer_id: *buffer_id, pane_id: self.editor.pane_id(), revision: *revision, }; if self.completion_is_current(update.request_id, source) && let Err(error) = self.editor.validate_completion(items) { self.pending_completion = false; self.editor .set_message(format!("LSP completion failed: {error}")); return Ok(true); } } } // Validate the whole applicable annotation batch before changing any buffer. let validation = (|| -> Result<(), String> { let mut label_bytes = 0usize; for effect in &update.effects { match effect { UpdateEffect::Diagnostics { buffer_id, revision, items, } => { self.editor .validate_diagnostics(*buffer_id, *revision, items)?; } UpdateEffect::SemanticTokens { buffer_id, revision, tokens, } => { if owner != "core" { return Err("Semantic tokens are native LSP updates only".into()); } self.editor.validate_semantic_tokens( *buffer_id, *revision, tokens.as_deref(), )?; } UpdateEffect::Navigate { buffer_id, revision, path, .. } | UpdateEffect::NavigateUtf16 { buffer_id, revision, path, .. } => { if owner != "core" && matches!(effect, UpdateEffect::NavigateUtf16 { .. }) { return Err("UTF-16 navigation is a native LSP update only".into()); } let source = InvocationSource { buffer_id: *buffer_id, pane_id: self.editor.pane_id(), revision: *revision, }; if self.request_is_current(owner, update.request_id, source) && (path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control)) { return Err( "Navigation path must be nonempty and contain no controls".into() ); } } UpdateEffect::Hover { buffer_id, revision, text, } => { let source = InvocationSource { buffer_id: *buffer_id, pane_id: self.editor.pane_id(), revision: *revision, }; if self.hover_is_current(owner, update.request_id, source) { crate::editor::validate_hover_text(text)?; } } UpdateEffect::Completion { .. } => {} UpdateEffect::Message { .. } => {} UpdateEffect::Label { name, text } => { crate::editor::validate_label(name, text)?; label_bytes = label_bytes .checked_add(name.len()) .and_then(|bytes| bytes.checked_add(text.len())) .ok_or("Labels exceed 4 MiB")?; if label_bytes > 4 * 1024 * 1024 { return Err("Labels exceed 4 MiB".into()); } } } } Ok(()) })(); if let Err(error) = validation { if owner == "core" { self.editor.clear_semantic_tokens(); } return Err(error); } let mut changed = false; for effect in update.effects { match effect { UpdateEffect::Diagnostics { buffer_id, revision, items, } => { changed |= self .editor .replace_validated_diagnostics(owner, buffer_id, revision, items); } UpdateEffect::SemanticTokens { buffer_id, revision, tokens, } => { changed |= self .editor .replace_validated_semantic_tokens(buffer_id, revision, tokens); } UpdateEffect::NavigateUtf16 { buffer_id, revision, path, range, target_range, } => { let source = InvocationSource { buffer_id, pane_id: self.editor.pane_id(), revision, }; if self.request_is_current(owner, update.request_id, source) { if let Err(error) = self.editor.navigate_lsp_to(path, range, target_range) { self.editor .set_message(format!("LSP: definition failed: {error}")); } changed = true; } } UpdateEffect::Navigate { buffer_id, revision, path, row, column, } => { let source = InvocationSource { buffer_id, pane_id: self.editor.pane_id(), revision, }; if self.request_is_current(owner, update.request_id, source) { if let Err(error) = self.editor.navigate_to(path, TextPosition { row, column }) { self.editor .set_message(format!("Navigation failed: {error}")); } changed = true; } } UpdateEffect::Hover { buffer_id, revision, text, } => { let source = InvocationSource { buffer_id, pane_id: self.editor.pane_id(), revision, }; if self.hover_is_current(owner, update.request_id, source) { self.editor.show_hover(text)?; self.hover_owner = Some(owner.into()); if owner == "core" { self.pending_hover = false; } changed = true; } } UpdateEffect::Completion { buffer_id, revision, items, } => { let source = InvocationSource { buffer_id, pane_id: self.editor.pane_id(), revision, }; if self.completion_is_current(update.request_id, source) { self.pending_completion = false; if let Err(error) = self.editor.show_completion(items) { self.editor .set_message(format!("LSP completion failed: {error}")); } changed = true; } } UpdateEffect::Message { text } => { changed |= self.editor.message() != text; self.editor.set_message(text); } UpdateEffect::Label { name, text } => { changed |= self.editor.set_label(name, text)?; } } } self.editor.refresh_live_diff(); Ok(changed) } fn apply_response( &mut self, owner: &str, id: u64, mut effects: Vec, ) -> Result { let source = self .requests .get_mut(owner) .and_then(|requests| requests.pending.remove(&id)); let Some(source) = source else { return Err("plugin response has an unknown invocation ID".into()); }; // Document effects retain source freshness; labels belong to the whole session. let current = self.source_is_current(source) && self.editor.buffer_id() == source.buffer_id && self.editor.pane_id() == source.pane_id; if !current { effects.retain(|effect| matches!(effect, Effect::Label { .. })); } if effects .iter() .all(|effect| matches!(effect, Effect::Message(_) | Effect::Label { .. })) { crate::editor::validate_effects(&effects)?; let mut changed = false; for effect in effects { match effect { Effect::Message(text) => { changed |= self.editor.message() != text; self.editor.set_message(text); } Effect::Label { name, text } => { changed |= self.editor.set_label(name, text)?; } _ => unreachable!("presentation-only effects checked above"), } } return Ok(changed); } if self.editor.mutation_blocked() { return Ok(false); } let changed = !effects.is_empty(); self.editor.apply(effects)?; Ok(changed) } fn poll_gutter_at(&mut self, now: Instant) -> bool { let line_gutter = self.editor.gutter_style() != crate::gutter::GutterStyle::None; let picker_changed = match (self.editor.normal_file_picker(), &self.picker_source) { (Some(picker), Some((tab, root))) => { *tab != self.editor.tab_id() || *root != picker.root } (None, None) => false, _ => true, }; if self.editor.should_quit() || (!line_gutter && self.editor.normal_file_picker().is_none()) { self.gutter_worker = None; self.gutter_sources.clear(); self.gutter_pending = false; self.picker_source = None; return false; } let sources_changed = !self .editor .documents() .filter(|document| line_gutter || document.dirty) .filter_map(|document| { document.path.map(|path| { ( document.id, document.revision, document.save_revision, path, document.dirty, ) }) }) .eq(self.gutter_sources.iter().map(|source| { ( source.buffer, source.revision, source.save_revision, source.path.as_path(), source.dirty, ) })); let refresh_due = !self.gutter_pending && self .last_gutter_refresh .is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1)); if sources_changed || picker_changed || refresh_due { if picker_changed { self.picker_source = self .editor .normal_file_picker() .map(|picker| (self.editor.tab_id(), picker.root.clone())); } if sources_changed { self.gutter_sources = self .editor .documents() .filter(|document| line_gutter || document.dirty) .filter_map(|document| { let path = document.path?; let contents = self .gutter_sources .binary_search_by_key(&document.id, |source| source.buffer) .ok() .map(|index| &self.gutter_sources[index]) .filter(|source| { source.revision == document.revision && source.path == path }) .map_or_else( || Arc::from(document.lines.join("\n")), |source| Arc::clone(&source.contents), ); Some(GutterSource { buffer: document.id, revision: document.revision, save_revision: document.save_revision, path: path.to_path_buf(), dirty: document.dirty, contents, }) }) .collect(); } self.gutter_request_id = self.gutter_request_id.wrapping_add(1); self.last_gutter_refresh = Some(now); if self.gutter_sources.is_empty() && self.picker_source.is_none() { self.gutter_worker = None; self.gutter_pending = false; return false; } if self.gutter_worker.is_none() { match crate::gutter::Worker::start() { Ok(worker) => self.gutter_worker = Some(worker), Err(error) => { self.editor.set_message(format!("Gutter: {error}")); return true; } } } let files = self .gutter_sources .iter() .filter(|_| line_gutter) .map(|source| { ( source.buffer, source.path.clone(), Arc::clone(&source.contents), ) }) .collect(); let picker = self.picker_source.as_ref().map(|(_, root)| { let buffers = self .gutter_sources .iter() .filter(|source| source.dirty) .map(|source| (source.path.clone(), Arc::clone(&source.contents))) .collect(); (root.clone(), buffers) }); match self .gutter_worker .as_mut() .expect("gutter worker started") .request(self.gutter_request_id, files, picker) { Ok(()) => self.gutter_pending = true, Err(error) => { self.gutter_worker = None; self.gutter_pending = false; self.editor.set_message(format!("Gutter: {error}")); return true; } } } let mut changed = false; while let Some((id, result, picker)) = self .gutter_worker .as_mut() .and_then(crate::gutter::Worker::poll) { changed |= self.apply_gutter_result(id, result); if let Some(picker) = picker { changed |= self.apply_picker_result(id, picker); } } changed } fn apply_gutter_result(&mut self, id: u64, result: crate::gutter::ResultBatch) -> bool { if id != self.gutter_request_id { return false; } self.gutter_pending = false; let mut changed = false; for (buffer, result) in result { let Some(source) = self .gutter_sources .iter() .find(|source| source.buffer == buffer) else { continue; }; let changes = match result { Ok(changes) => changes, Err(error) => { self.editor.set_message(format!("Gutter: {error}")); changed = true; Vec::new() } }; changed |= self.editor .set_gutter_changes(buffer, source.revision, &source.path, changes); } changed } fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool { let Some((tab, root)) = &self.picker_source else { return false; }; if id != self.gutter_request_id || *tab != self.editor.tab_id() || *root != reply.root || self .editor .normal_file_picker() .is_none_or(|picker| picker.root != *root) { return false; } let tab = *tab; let mut changed = false; let changes = match reply.result { Ok(changes) => changes, Err(error) => { self.editor.set_message(format!("File status: {error}")); changed = true; Vec::new() } }; changed | self .editor .set_picker_repository_changes(tab, &reply.root, changes) } pub(crate) fn refresh_gutter(&mut self) -> bool { self.last_gutter_refresh = None; self.poll_gutter_at(Instant::now()) } fn poll_blame(&mut self) -> bool { let mut changed = false; if !self .blame_source .as_ref() .is_some_and(|source| self.editor.blame_file_source_is_current(source)) { self.blame_source = self.editor.blame_source(); self.blame_request_id = self.blame_request_id.wrapping_add(1); self.blame_lines = None; changed |= self.editor.clear_line_blame(); if let Some(source) = &self.blame_source { if self.blame_worker.is_none() { self.blame_worker = BlameWorker::start().ok(); } if let Some(worker) = &mut self.blame_worker && worker .request( self.blame_request_id, source.path.to_path_buf(), self.editor.lines().join("\n"), ) .is_err() { self.blame_worker = None; } } else { self.blame_worker = None; } } while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) { changed |= self.apply_blame_result(id, result); } changed | self.refresh_line_blame() } fn apply_blame_result( &mut self, id: u64, result: Result>>>, String>, ) -> bool { if id != self.blame_request_id || !self .blame_source .as_ref() .is_some_and(|source| self.editor.blame_file_source_is_current(source)) { return false; } self.blame_lines = result.ok().flatten(); self.refresh_line_blame() } fn refresh_line_blame(&mut self) -> bool { if let Some(source) = &self.blame_source && self.editor.blame_file_source_is_current(source) && let Some(blame) = self .blame_lines .as_ref() .and_then(|lines| lines.get(self.editor.cursor().row)) .and_then(Option::as_ref) { self.editor.set_cached_line_blame(source, blame.clone()) } else { self.editor.clear_line_blame() } } pub(crate) fn refresh_blame(&mut self) -> bool { self.blame_source = None; self.poll_blame() } fn poll_battery(&mut self) -> bool { let enabled = self.editor.label("battery").is_empty() && [ self.editor.top_bar_config(), self.editor.bottom_bar_config(), ] .into_iter() .any(|bar| { bar.left .iter() .chain(&bar.center) .chain(&bar.right) .any(|name| name == "battery") }); let mut changed = false; if enabled != self.battery_enabled { self.battery_enabled = enabled; // Unit sessions do not read external hardware; provider and renderer checks inject data. #[cfg(not(test))] { self.battery = if enabled { crate::battery::Battery::start().ok() } else { None }; } if !enabled { changed |= self.editor.set_battery_charge(None); } } if let Some(charge) = self.battery.as_mut().and_then(|battery| battery.poll()) { changed |= self.editor.set_battery_charge(charge); } changed } fn refresh_files_at(&mut self, now: Instant) -> bool { if now.saturating_duration_since(self.last_file_refresh) < Duration::from_millis(250) { return false; } self.last_file_refresh = now; self.editor.refresh_files() } fn refresh_checkout_diffs_at(&mut self, now: Instant) { if now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1) { return; } self.last_diff_refresh = now; self.editor .queue_checkout_refreshes(self.diff_follow_working_copy); } /// Drains ready plugin work without waiting for command or server responses. pub fn poll(&mut self) -> bool { self.poll_at(Instant::now()) } fn poll_at(&mut self, now: Instant) -> bool { let mut changed = self.refresh_files_at(now); changed |= self.poll_gutter_at(now); changed |= self.synchronize_documents(); changed |= self.poll_battery(); self.refresh_checkout_diffs_at(now); changed |= self.poll_diff(); for update in self.lsp.poll() { match self.apply_update("core", update) { Ok(applied) => changed |= applied, Err(error) => { self.editor.clear_plugin_diagnostics("core"); self.editor.set_message(format!("LSP: {error}")); changed = true; } } } let mut ready = Vec::new(); for (owner, plugin) in &mut self.plugins { if plugin.is_async() { let result = plugin.poll(); if match &result { Ok(events) => !events.is_empty(), Err(_) => true, } { ready.push((owner.clone(), result)); } } } for (owner, events) in ready { let events = match events { Ok(events) => events, Err(error) => { self.retire(&owner, &error); changed = true; continue; } }; for event in events { let result = match event { PluginEvent::Response { id, effects } => { self.apply_response(&owner, id, effects) } PluginEvent::Update(update) => self.apply_update(&owner, update), }; match result { Ok(applied) => changed |= applied, Err(error) => { self.retire(&owner, &error); changed = true; break; } } } } changed |= self.synchronize_documents(); changed } } #[cfg(test)] mod tests { use super::*; use std::{ fs, sync::atomic::{AtomicU64, Ordering}, }; #[test] fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() { use crate::diff::{PullRequestPage, RepoKind}; let (outgoing, results) = mpsc::channel(); let (started, starts) = mpsc::channel(); let (open, open_gate) = mpsc::channel::>(); let (closed, closed_gate) = mpsc::channel::>(); let mut gates = [Some(open_gate), Some(closed_gate)]; let cancelled = Arc::new(AtomicBool::new(false)); let fetches = std::array::from_fn(|index| { let gate = gates[index].take().unwrap(); let started = started.clone(); let cancelled = Arc::clone(&cancelled); move |page| { started.send((index, page)).unwrap(); let has_more = loop { match gate.try_recv() { Ok(result) => break result?, Err(mpsc::TryRecvError::Empty) if !cancelled.load(Ordering::Acquire) => { thread::yield_now() } Err(_) => return Err("cancelled".into()), } }; Ok(PullRequestPage { kind: RepoKind::Git, remote: "origin".into(), entries: vec![], has_more, }) } }); let mut worker = PullRequestWorker::start_with(7, outgoing, cancelled, fetches).unwrap(); let mut first = [ starts.recv_timeout(Duration::from_secs(2)).unwrap(), starts.recv_timeout(Duration::from_secs(2)).unwrap(), ]; first.sort_unstable(); assert_eq!(first, [(0, 1), (1, 1)]); let receive = |worker: &mut PullRequestWorker| { let (id, result) = results.recv_timeout(Duration::from_secs(2)).unwrap(); assert_eq!(id, 7); let Ok(DiffOutcome::PullRequestPage { closed, result }) = result else { panic!("expected independently published page"); }; worker.complete(closed, &result); (closed, result) }; closed.send(Ok(true)).unwrap(); let (category, page) = receive(&mut worker); assert!(category && page.unwrap().has_more); // Open's first request remains blocked while the active Closed category advances. worker.update(1, [0, 0]).unwrap(); assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2)); closed.send(Err("late failure".into())).unwrap(); assert_eq!(receive(&mut worker).1.unwrap_err(), "late failure"); worker.update(1, [0, 0]).unwrap(); assert!(starts.try_recv().is_err()); worker.update(1, [0, 1]).unwrap(); assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2)); closed.send(Ok(false)).unwrap(); assert!(!receive(&mut worker).1.unwrap().has_more); open.send(Ok(false)).unwrap(); assert!(!receive(&mut worker).0); worker.update(2, [0, 1]).unwrap(); assert!(starts.try_recv().is_err()); } #[test] fn closing_pull_request_listing_cancels_both_outstanding_reads() { let (outgoing, _results) = mpsc::channel(); let (started, starts) = mpsc::channel(); let cancelled = Arc::new(AtomicBool::new(false)); let fetches = std::array::from_fn(|index| { let started = started.clone(); let cancelled = Arc::clone(&cancelled); move |_| { started.send(index).unwrap(); while !cancelled.load(Ordering::Acquire) { thread::yield_now(); } Err("cancelled".into()) } }); let worker = PullRequestWorker::start_with(1, outgoing, Arc::clone(&cancelled), fetches).unwrap(); starts.recv_timeout(Duration::from_secs(2)).unwrap(); starts.recv_timeout(Duration::from_secs(2)).unwrap(); drop(worker); assert!(cancelled.load(Ordering::Acquire)); } #[test] fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() { use crate::diff::FileStatus; let fixture = Fixture::new(); fixture.file("buffer.txt", "base\n"); fixture.file("clean.txt", "clean\n"); let git = |args: &[&str]| { let output = std::process::Command::new("git") .args(args) .current_dir(&fixture.0) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_AUTHOR_NAME", "Author") .env("GIT_AUTHOR_EMAIL", "author@example.com") .env("GIT_COMMITTER_NAME", "Author") .env("GIT_COMMITTER_EMAIL", "author@example.com") .env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z") .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); }; git(&["init", "-q", "--template="]); git(&["add", "buffer.txt", "clean.txt"]); git(&["commit", "-qm", "baseline"]); fixture.file("buffer.txt", "disk\n"); fixture.file("added.txt", "new\n"); let mut session = fixture.open( "Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n", ); session.editor.set_project_root(fixture.0.clone()); let now = session.last_file_refresh; let open = |session: &mut EditorSession| { session.handle(Input::Char(' ')); session.handle(Input::Char('o')); }; let ready = |session: &mut EditorSession, clock: Instant, expected| { let deadline = Instant::now() + Duration::from_secs(10); loop { session.poll_gutter_at(clock); let picker = session.editor.normal_file_picker().unwrap(); let status = picker .entries() .find(|row| row.path == Path::new("buffer.txt")) .expect("normal picker retained the buffer file") .status; if !session.gutter_pending && status == expected { assert!( picker.entries().any(|row| { row.path == Path::new("clean.txt") && row.status.is_none() }) ); assert!(picker.entries().any(|row| { row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added) })); break; } assert!( Instant::now() < deadline, "normal picker status did not settle: {}", session.editor.message() ); thread::sleep(Duration::from_millis(5)); } }; open(&mut session); ready(&mut session, now, Some(FileStatus::Modified)); session.handle(Input::Ctrl('w')); session.poll_gutter_at(now); session .editor .apply(vec![Effect::Replace("base\n".into())]) .unwrap(); assert!(session.editor.dirty()); open(&mut session); ready(&mut session, now, None); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "disk\n" ); session.handle(Input::Ctrl('w')); session.poll_gutter_at(now); session.handle(Input::Char('u')); open(&mut session); ready(&mut session, now, Some(FileStatus::Modified)); git(&["add", "buffer.txt"]); git(&["commit", "-qm", "new baseline"]); ready(&mut session, now + Duration::from_secs(1), None); } #[test] fn gutter_results_follow_buffer_identity_and_reject_stale_edits() { use crate::gutter::Change; let fixture = Fixture::new(); let first_path = fixture.file("first.txt", "first\nsecond"); let second_path = fixture.file("second.txt", "other"); let mut session = EditorSession::open(Some(first_path), vec![], None).unwrap(); let now = session.last_gutter_refresh.unwrap(); let first_buffer = session.editor.buffer_id(); let first_request = session.gutter_request_id; assert!(session.apply_gutter_result( first_request, vec![(first_buffer, Ok(vec![None, Some(Change::Modified)]))], )); session.editor.handle(Input::Down); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); session .editor .execute(&format!("edit {}", second_path.display())); session.poll_gutter_at(now); let second_buffer = session.editor.buffer_id(); let second_request = session.gutter_request_id; assert_ne!(second_request, first_request); assert!(!session.apply_gutter_result( first_request, vec![(first_buffer, Ok(vec![Some(Change::Removed), None]))], )); assert!(session.apply_gutter_result( second_request, vec![ (first_buffer, Ok(vec![None, Some(Change::Modified)])), (second_buffer, Ok(vec![Some(Change::Added)])), ], )); assert_eq!(session.editor.gutter_change(0), Some(Change::Added)); session.editor.execute(&format!("buffer {first_buffer}")); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); session.editor.handle(Input::Char('i')); session.editor.handle(Input::Char('x')); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); session.editor.handle(Input::Char('y')); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); session.editor.handle(Input::Backspace); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); session.editor.handle(Input::Escape); session.editor.handle(Input::Char('u')); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); session.editor.handle(Input::Char('U')); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); assert!(!session.apply_gutter_result( second_request, vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))], )); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); session.poll_gutter_at(now); assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); assert!(session.apply_gutter_result( session.gutter_request_id, vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))], )); assert_eq!(session.editor.gutter_change(1), Some(Change::Added)); session.poll_gutter_at(now + Duration::from_secs(1)); assert!(session.apply_gutter_result( session.gutter_request_id, vec![(first_buffer, Ok(vec![None, None]))], )); assert_eq!(session.editor.gutter_change(1), None); session.editor.execute(&format!("buffer {second_buffer}")); assert_eq!(session.editor.gutter_change(0), Some(Change::Added)); } #[test] fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() { use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget}; let fixture = Fixture::new(); fixture.file("buffer.txt", "base"); for working in [true, false] { let mut session = fixture.open(""); session.editor.execute("diff"); let (id, _) = session.editor.take_diff_request().unwrap(); session.editor.apply_diff_result( id, Ok(DiffOutcome::Catalog(Catalog { kind: RepoKind::Git, notice: None, entries: [ RevisionTarget::Working, RevisionTarget::Commit("base".into()), ] .into_iter() .map(|target| Revision { target, label: "revision".into(), description: String::new(), category: Category::Commits, }) .collect(), })), ); session.editor.handle(Input::Down); session.editor.handle(Input::Enter); if !working { session.editor.handle(Input::Down); } session.editor.handle(Input::Enter); let (id, _) = session.editor.take_diff_request().unwrap(); session.editor.apply_diff_result( id, Ok(DiffOutcome::Comparison(Comparison { root: fixture.0.clone(), before: "base".into(), after: "after".into(), files: Vec::new(), editable_paths: Default::default(), })), ); let now = session.last_diff_refresh; for (tick, follow) in [(1, false), (2, true)] { session.diff_follow_working_copy = follow; session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1)); assert!(session.editor.take_next_diff_request().is_none()); session.refresh_checkout_diffs_at(now + Duration::from_secs(tick)); let request = session.editor.take_next_diff_request(); assert_eq!(request.is_some(), !working || follow); if let Some((_, refresh, operation)) = request { assert!( matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. } if comparison_id == id && follow_working == follow) ); assert!( !session .editor .apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None))) ); } } } } #[test] fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() { let fixture = Fixture::new(); let path = fixture.file("source.txt", "first\nsecond\nthird"); let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); assert!(session.editor.git_blame()); let first = session.blame_request_id; let original = Arc::new(crate::blame::LineBlame { commit: "0123456789".into(), author: "Author".into(), summary: "Original".into(), uncommitted: false, }); let second = Arc::new(crate::blame::LineBlame { commit: "0123456789".into(), author: "Author".into(), summary: "Second".into(), uncommitted: false, }); let blame = || { Ok(Some(vec![ Some(original.clone()), Some(second.clone()), Some(second.clone()), ])) }; session.handle(Input::Down); assert_eq!(session.blame_request_id, first); session.blame_worker = None; assert!(session.apply_blame_result(first, blame())); assert_eq!( session.editor.line_blame(), Some("Author · Second (01234567)") ); let label = session.editor.line_blame().unwrap().as_ptr(); session.handle(Input::Down); assert_eq!(session.editor.line_blame().unwrap().as_ptr(), label); assert!(!session.poll_blame()); session.handle(Input::Up); session.handle(Input::Up); assert_eq!( session.editor.line_blame(), Some("Author · Original (01234567)") ); assert_eq!(session.blame_request_id, first); session.execute("tab.new"); assert_eq!(session.blame_request_id, first); assert_eq!( session.editor.line_blame(), Some("Author · Original (01234567)") ); let other = fixture.file("other.txt", "other"); session.execute(&format!("edit {}", other.display())); assert!(session.editor.line_blame().is_none()); assert!(!session.apply_blame_result(first, blame())); session.execute("buffer 1"); assert!(!session.apply_blame_result(first, blame())); let source = session.editor.blame_source().unwrap(); let before_edit = session.blame_request_id; session.handle(Input::Char('i')); session.handle(Input::Char('x')); assert_ne!(session.blame_request_id, before_edit); assert!(!session.apply_blame_result(before_edit, blame())); assert!(!session.editor.set_line_blame(source, original.clone())); assert!(session.editor.line_blame().is_none()); session.handle(Input::Escape); session.execute("git_blame.toggle"); assert!(!session.editor.git_blame()); assert!(session.blame_worker.is_none()); assert!(session.blame_source.is_none()); assert!(session.editor.line_blame().is_none()); assert!(!session.apply_blame_result(session.blame_request_id, blame())); session.poll_blame(); assert!(session.blame_worker.is_none()); let config = fixture.file( "config.rb", "Vamp.configure { |config| config.git_blame = false }", ); let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap(); assert!(!disabled.editor.git_blame()); assert!(disabled.blame_worker.is_none()); disabled.poll_blame(); assert!(disabled.blame_worker.is_none()); } #[test] fn blame_navigation_uses_one_real_file_result_and_refreshes_its_baseline() { let fixture = Fixture::new(); let path = fixture.file("source.txt", "first\nsecond\n"); let git = |args: &[&str]| { let output = std::process::Command::new("git") .args(args) .current_dir(&fixture.0) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_AUTHOR_NAME", "Author") .env("GIT_AUTHOR_EMAIL", "author@example.com") .env("GIT_COMMITTER_NAME", "Author") .env("GIT_COMMITTER_EMAIL", "author@example.com") .env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z") .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); }; git(&["init", "-q"]); git(&["add", "source.txt"]); git(&["commit", "-qm", "Original"]); fixture.file("source.txt", "first\nchanged\n"); git(&["add", "source.txt"]); git(&["commit", "-qm", "Second"]); let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); let ready = |session: &mut EditorSession| { let deadline = Instant::now() + Duration::from_secs(10); while session.blame_lines.is_none() { assert!(Instant::now() < deadline, "file attribution did not finish"); session.poll(); thread::sleep(Duration::from_millis(5)); } }; let first = session.blame_request_id; session.handle(Input::Down); assert_eq!(session.blame_request_id, first); ready(&mut session); assert!(session.editor.line_blame().unwrap().contains("Second")); session.handle(Input::Up); assert!(session.editor.line_blame().unwrap().contains("Original")); session.handle(Input::Down); assert!(session.editor.line_blame().unwrap().contains("Second")); session.handle(Input::Down); assert!(session.editor.line_blame().is_none()); assert_eq!(session.blame_request_id, first); session.handle(Input::Up); session.handle(Input::Char('i')); session.handle(Input::Char('x')); assert_ne!(session.blame_request_id, first); ready(&mut session); assert_eq!(session.editor.line_blame(), Some("Uncommitted")); session.handle(Input::Escape); let edited = session.blame_request_id; session.execute("save"); assert_ne!(session.blame_request_id, edited); ready(&mut session); git(&["add", "source.txt"]); git(&["commit", "-qm", "Saved"]); let saved = session.blame_request_id; session.refresh_blame(); assert_ne!(session.blame_request_id, saved); ready(&mut session); assert!(session.editor.line_blame().unwrap().contains("Saved")); fixture.file("source.txt", "first\nxchanged\n"); let refreshed = session.blame_request_id; assert!(!session.editor.refresh_files()); session.synchronize_documents(); assert_ne!(session.blame_request_id, refreshed); ready(&mut session); session.handle(Input::Char('i')); session.handle(Input::Char('x')); ready(&mut session); assert!(session.editor.dirty()); fs::remove_file(path).unwrap(); assert!(session.editor.refresh_files()); session.synchronize_documents(); assert!(session.blame_source.is_none()); assert!(session.editor.line_blame().is_none()); } #[test] fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() { use crate::plugin::Utf16Range; let fixture = Fixture::new(); let source = fixture.file("buffer.txt", "source"); let target = fixture.file("target.txt", "A😀e\u{301}Z\n"); let mut session = EditorSession::open(Some(source.clone()), vec![], None).unwrap(); session.dispatch(Invocation { plugin: "core".into(), command: "lsp.definition".into(), args: String::new(), }); let buffer_id = session.editor.buffer_id(); let revision = session.editor.text_revision(); let request_id = session.lsp_request_id; let span = Utf16Range { start: (0, 5), end: (0, 6), }; let invalid = Utf16Range { end: (9, 0), ..span }; let update = |range, target_range| PluginUpdate { request_id: Some(request_id), effects: vec![UpdateEffect::NavigateUtf16 { buffer_id, revision, path: target.clone(), range, target_range, }], }; for (range, target_range) in [(invalid, None), (span, Some(invalid))] { assert!( session .apply_update("core", update(range, target_range)) .unwrap() ); assert_eq!(session.editor.buffer_id(), buffer_id); assert_eq!(session.editor.path(), Some(source.as_path())); assert_eq!(session.editor.buffer_count(), 1); assert_eq!(session.editor.tab_count(), 1); assert_eq!(session.editor.cursor(), Cursor::default()); assert!(session.editor.message().contains("definition failed")); } assert!(session.apply_update("fixture", update(span, None)).is_err()); assert_eq!(session.editor.buffer_id(), buffer_id); assert!( session .apply_update("core", update(span, Some(span))) .unwrap() ); assert_eq!(session.editor.path(), Some(target.as_path())); assert_eq!(session.editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(session.editor.tab_count(), 1); session.handle(Input::Ctrl('o')); assert_eq!(session.editor.path(), Some(source.as_path())); assert_eq!(session.editor.cursor(), Cursor::default()); } static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); #[test] fn render_swaps_preserve_session_sources_and_pending_effects_do_not_cross_panes() { let fixture = Fixture::new(); let path = fixture.file("buffer.txt", "source"); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let source = InvocationSource { pane_id: session.editor.pane_id(), buffer_id: session.editor.buffer_id(), revision: session.editor.text_revision(), }; session .requests .entry("fixture".into()) .or_default() .pending .insert(1, source); session.requests.entry("fixture".into()).or_default().latest = Some((1, source)); session.handle(Input::Char('|')); assert_eq!(session.editor.buffer_id(), source.buffer_id); assert_eq!(session.editor.cursor(), Cursor::default()); assert!(!session.request_is_current("fixture", Some(1), source)); assert!( !session .apply_response("fixture", 1, vec![Effect::Insert("wrong pane".into())]) .unwrap() ); session.requests.entry("core".into()).or_default().latest = Some(( 2, InvocationSource { pane_id: session.editor.pane_id(), ..source }, )); session.lsp_source_cursor = Some(session.editor.cursor()); session.lsp_source_mode = session.editor.mode(); session.lsp_source_view = Some(( session.editor.tab_id(), session.editor.diff().and_then(|diff| diff.comparison_id), )); session.editor_mut().with_render_pane(1, |pane| { assert_eq!(pane.pane_id(), 1); assert_eq!(pane.lines(), ["source"]); }); session.synchronize_documents(); assert!(session.requests["core"].latest.is_some()); assert_eq!(session.editor.pane_id(), 2); session.handle(Input::Alt('h')); assert!(session.requests["core"].latest.is_none()); assert_eq!(session.editor.lines(), ["source"]); } #[test] fn native_semantic_batches_are_atomic_and_malformed_updates_clear_hidden_overlays() { use crate::plugin::{SemanticToken, SemanticTokenKind}; use std::sync::Arc; let fixture = Fixture::new(); fixture.file("buffer.txt", "source"); let target = fixture.file("target.txt", "target"); let mut session = fixture.open(""); let token = SemanticToken { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 1 }, kind: SemanticTokenKind::Variable, modifiers: 0, }; let tokens: Arc<[SemanticToken]> = Arc::from([token]); let semantic = |buffer_id, revision, tokens| UpdateEffect::SemanticTokens { buffer_id, revision, tokens, }; let update = |effects| PluginUpdate { request_id: None, effects, }; session .apply_update("core", update(vec![semantic(1, 0, Some(tokens.clone()))])) .unwrap(); session.execute(&format!("edit {}", target.display())); session .apply_update("core", update(vec![semantic(2, 0, Some(tokens.clone()))])) .unwrap(); let diagnostic = crate::plugin::Diagnostic { start: token.start, end: token.end, severity: crate::plugin::DiagnosticSeverity::Warning, message: "unchanged".into(), }; session .editor .replace_diagnostics("plugin", 2, 0, vec![diagnostic.clone()]) .unwrap(); let malformed: Arc<[SemanticToken]> = Arc::from([SemanticToken { end: TextPosition { row: 99, column: 0 }, ..token }]); let before_message = session.editor.message().to_owned(); let result = session.apply_update( "core", update(vec![ UpdateEffect::Message { text: "must not apply".into(), }, UpdateEffect::Diagnostics { buffer_id: 2, revision: 0, items: vec![diagnostic.clone()], }, semantic(1, 0, Some(tokens.clone())), semantic(2, 0, Some(malformed.clone())), ]), ); assert!(result.is_err()); assert_eq!(session.editor.message(), before_message); assert!(session.editor.semantic_tokens().is_empty()); assert_eq!(session.editor.diagnostics().count(), 1); session.execute("buffer 1"); assert!(session.editor.semantic_tokens().is_empty()); assert!( session .apply_update( "core", update(vec![ semantic(999, 0, Some(malformed.clone())), semantic(1, 99, Some(malformed)), semantic(1, 0, Some(tokens.clone())), ]) ) .unwrap() ); assert!(std::ptr::eq( session.editor.semantic_tokens(), tokens.as_ref() )); assert_eq!(session.editor.lines(), ["source"]); assert_eq!(session.editor.text_revision(), 0); assert!(!session.editor.dirty()); assert!( session .apply_update("core", update(vec![semantic(1, 0, None)])) .unwrap() ); assert!(session.editor.semantic_tokens().is_empty()); } struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let path = std::env::temp_dir().join(format!( "vamp-session-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed), )); fs::create_dir(&path).unwrap(); Self(path) } fn file(&self, name: &str, text: &str) -> PathBuf { let path = self.0.join(name); fs::write(&path, text).unwrap(); path } fn open(&self, config: &str) -> EditorSession { let config = self.file("config.rb", config); let mut session = EditorSession::open( Some(self.0.join("buffer.txt")), vec!["text".into()], Some(&config), ) .unwrap(); // Legacy editing scenarios exercise the internal register, never the user's desktop. session.editor.set_clipboard_mode("separate").unwrap(); session } #[cfg(unix)] fn system_session(&self, config: &str) -> EditorSession { use std::os::unix::fs::PermissionsExt; let config = self.file("config.rb", config); let mut session = EditorSession::open( Some(self.0.join("buffer.txt")), vec!["text".into()], Some(&config), ) .unwrap(); self.file("system.txt", ""); let reader = self.file("clip-read", "#!/bin/sh\n[ -e \"${0%/*}/read.fail\" ] && exit 13\nexec /bin/cat \"${0%/*}/system.txt\"\n"); let writer = self.file("clip-write", "#!/bin/sh\n[ -e \"${0%/*}/write.fail\" ] && exit 13\nexec /bin/cat > \"${0%/*}/system.txt\"\n"); fs::set_permissions(&reader, fs::Permissions::from_mode(0o700)).unwrap(); fs::set_permissions(&writer, fs::Permissions::from_mode(0o700)).unwrap(); session .editor .set_system_clipboard(crate::clipboard::SystemClipboard::with_commands( reader, writer, )); session } fn healthy(&self) { self.file( "healthy.rb", r#" Vamp.plugin "healthy", requires: ["text"] Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" } "#, ); } fn asynchronous(&self, file: Option) -> EditorSession { self.file( "generic.rb", r#" Vamp.plugin "generic_fixture", requires: ["text", "diagnostics"], async: true log_mutex = Mutex.new record = ->(value) { log_mutex.synchronize { File.open(Vamp.options.fetch("log"), "a") { |file| file.puts JSON.generate(value) } } } Vamp.on_document { |event| record.call(event) } Vamp.command("fixture_barrier") { |editor, args| editor.message args } Vamp.command("fixture_replace") { |editor, args| editor.replace args } Vamp.command("fixture_crash") { |_editor, _args| Process.exit!(7) } ["fixture_publish", "fixture_hold"].each do |name| Vamp.command(name) do |editor, args| payload = JSON.parse(args) effects = payload.fetch("effects") effects.each do |effect| next unless ["diagnostics", "navigate"].include?(effect["type"]) effect["buffer_id"] ||= editor.buffer_id effect["revision"] ||= editor.revision end record.call({"invoke" => name, "buffer_id" => editor.buffer_id, "revision" => editor.revision, "row" => editor.row, "column" => editor.column, "request_id" => editor.request_id}) if name == "fixture_hold" Thread.new do gate = payload.fetch("gate") sleep 0.002 until File.exist?(gate) Vamp.publish(effects + [{"type" => "message", "text" => "delivered #{gate}"}], request_id: editor.request_id) end else Vamp.publish(effects, request_id: editor.request_id) end end end "#, ); let log = self.0.join("events.jsonl"); self.file("events.jsonl", ""); let config = self.file( "async-config.rb", &format!( "Vamp.configure {{ |config| config.clipboard_mode = \"separate\"; config.plugin \"generic.rb\", options: {{log: {}}} }}", serde_json::to_string(&log.to_string_lossy()).unwrap(), ), ); EditorSession::open( file, vec!["text".into(), "diagnostics".into()], Some(&config), ) .unwrap() } fn events(&self) -> Vec { fs::read_to_string(self.0.join("events.jsonl")) .unwrap() .lines() .map(|line| serde_json::from_str(line).unwrap()) .collect() } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[cfg(unix)] #[test] fn invalid_clipboard_policy_retains_system_default_and_valid_bindings() { let fixture = Fixture::new(); fixture.file("buffer.txt", "abc"); let mut session = fixture.system_session( r#" Vamp.configure do |config| config.clipboard_mode = "combined" config.bind "n", config.core.right end "#, ); assert_eq!( session.editor().clipboard_mode(), crate::clipboard::ClipboardMode::System ); session.handle(Input::Char('n')); session.handle(Input::Char('v')); session.handle(Input::Char('y')); assert_eq!( fs::read_to_string(fixture.0.join("system.txt")).unwrap(), "b" ); assert_eq!(session.editor().lines(), &["abc"]); } #[cfg(unix)] #[test] fn default_system_clipboard_tracks_external_text_and_preserves_cut_failures() { let fixture = Fixture::new(); fixture.file("buffer.txt", "alpha beta"); let mut session = fixture.system_session(""); assert_eq!( session.editor().clipboard_mode(), crate::clipboard::ClipboardMode::System ); fixture.file("system.txt", "external"); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["externalalpha beta"]); session.handle(Input::Char('u')); for ch in "vey".chars() { session.handle(Input::Char(ch)); } assert_eq!( fs::read_to_string(fixture.0.join("system.txt")).unwrap(), "alpha" ); fixture.file("system.txt", "outside"); session.handle(Input::Home); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["outsidealpha beta"]); fixture.file("system.txt", "fresh"); session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["outsideoutsidealpha beta"]); session.handle(Input::Char('u')); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["alpha beta"]); session.handle(Input::Home); session.handle(Input::Char('v')); session.handle(Input::Char('e')); let selection = session.editor().selections(); fixture.file("write.fail", ""); session.handle(Input::Char('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); assert_eq!(session.editor().selections(), selection); assert_eq!(session.editor().lines(), &["alpha beta"]); assert_eq!( fs::read_to_string(fixture.0.join("system.txt")).unwrap(), "fresh" ); fs::remove_file(fixture.0.join("write.fail")).unwrap(); session.handle(Input::Escape); session.handle(Input::Home); fixture.file("system.txt", "\u{1b}"); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["alpha beta"]); assert_eq!(session.editor().cursor(), crate::editor::Cursor::default()); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); } #[cfg(unix)] #[test] fn separate_clipboards_keep_internal_and_explicit_system_routes_independent() { let fixture = Fixture::new(); fixture.file("buffer.txt", "alpha beta"); let mut session = fixture.system_session( r#" Vamp.configure do |config| config.clipboard_mode = "separate" config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual config.bind "ctrl+g", config.core.paste_system_before end "#, ); fixture.file("system.txt", "outside"); for ch in "vey".chars() { session.handle(Input::Char(ch)); } assert_eq!( fs::read_to_string(fixture.0.join("system.txt")).unwrap(), "outside" ); for ch in "wve".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Ctrl('s')); assert_eq!( fs::read_to_string(fixture.0.join("system.txt")).unwrap(), "beta" ); session.handle(Input::Home); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["alphaalpha beta"]); session.handle(Input::Char('u')); session.handle(Input::Home); session.handle(Input::Ctrl('g')); assert_eq!(session.editor().lines(), &["betaalpha beta"]); session.handle(Input::Char('u')); session.handle(Input::Home); session.handle(Input::Char('v')); session.handle(Input::Char('e')); fixture.file("write.fail", ""); session.handle(Input::Char('d')); assert_eq!(session.editor().lines(), &[" beta"]); assert_eq!( fs::read_to_string(fixture.0.join("system.txt")).unwrap(), "beta" ); } #[test] fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() { let fixture = Fixture::new(); fixture.file("buffer.txt", "one two three\none two three"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "ctrl+j", nil, mode: config.modes.selection config.bind "ctrl+f", config.core.down, mode: config.modes.selection config.bind "ctrl+b", config.core.up, mode: config.modes.selection end "#, ); session.handle(Input::Down); session.handle(Input::Ctrl('p')); for ch in "core.word".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Ctrl('j')); assert_eq!(session.editor().palette().unwrap().selected, 0); assert_eq!( session.editor().cursor(), crate::editor::Cursor { row: 1, column: 0 } ); session.handle(Input::Ctrl('f')); assert_eq!( session.editor().palette().unwrap().entries[1].name, "core.word_next" ); assert_eq!(session.editor().palette().unwrap().selected, 1); session.handle(Input::Ctrl('b')); assert_eq!(session.editor().palette().unwrap().selected, 0); session.handle(Input::Ctrl('f')); session.handle(Input::Enter); assert!(session.editor().palette().is_none()); assert_eq!( session.editor().cursor(), crate::editor::Cursor { row: 1, column: 4 } ); session.handle(Input::Ctrl('k')); assert_eq!(session.editor().selection_count(), 2); assert_eq!( session.editor().lines(), &["one two three", "one two three"] ); } #[test] fn configured_picker_presentations_preserve_editing_and_docked_focus() { let fixture = Fixture::new(); fixture.file("buffer.txt", "alpha"); let mut session = fixture.open( r#" Vamp.configure do |config| config.file_explorer_side = "left" config.bind "ctrl+o", config.core.file_picker config.bind "ctrl+e", config.core.file_explorer end "#, ); assert_eq!( session.editor().file_explorer_side(), crate::files::DockSide::Left ); session.handle(Input::Ctrl('e')); assert_eq!( session.editor().file_picker().unwrap().presentation, crate::files::FilePresentation::Docked ); assert_eq!( session.editor().file_picker().unwrap().view, crate::files::FileView::Tree ); assert!(!session.editor().file_picker().unwrap().searching); session.handle(Input::Char('i')); for ch in "dir e\u{301}".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Backspace); assert_eq!(session.editor().file_picker().unwrap().query, "dir "); session.handle(Input::Ctrl('h')); assert_eq!(session.editor().file_picker().unwrap().query, "dir "); session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().searching); assert!(session.editor().file_picker().unwrap().focused); assert_eq!(session.editor().file_picker().unwrap().query, "dir "); session.handle(Input::Ctrl('u')); assert_eq!(session.editor().file_picker().unwrap().query, ""); assert!(!session.editor().file_picker().unwrap().searching); session.handle(Input::Char('i')); for ch in "dir ".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Ctrl('u')); assert_eq!(session.editor().file_picker().unwrap().query, ""); assert!(session.editor().file_picker().unwrap().searching); for ch in "dir ".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Escape); assert_eq!(session.editor().file_picker().unwrap().query, "dir "); session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().focused); session.handle(Input::Char('i')); for ch in " a b ".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Escape); assert_eq!(session.editor().lines(), &[" a b alpha"]); session.handle(Input::Ctrl('o')); assert_eq!( session.editor().file_picker().unwrap().presentation, crate::files::FilePresentation::Floating ); assert_eq!( session.editor().file_picker().unwrap().view, crate::files::FileView::Tree ); assert_eq!(session.editor().file_picker().unwrap().query, "dir "); assert!(session.editor().file_picker().unwrap().searching); session.handle(Input::Escape); assert!(session.editor().file_picker().unwrap().focused); session.handle(Input::Escape); assert!(session.editor().file_picker().is_none()); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["alpha"]); } #[test] fn configured_flat_dock_and_tree_picker_keep_control_h_distinct_from_backspace() { let fixture = Fixture::new(); fixture.file("buffer.txt", "alpha"); let mut session = fixture.open( r#" Vamp.configure do |config| config.file_explorer_view = "list" config.file_picker_view = "tree" config.bind "ctrl+f", config.core.right, mode: config.modes.tree end "#, ); session.handle(Input::Char(' ')); session.handle(Input::Char('e')); assert_eq!( session.editor().file_picker().unwrap().view, crate::files::FileView::List ); for ch in "e\u{301}".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Ctrl('h')); assert_eq!(session.editor().file_picker().unwrap().query, "e\u{301}"); session.handle(Input::Backspace); assert_eq!(session.editor().file_picker().unwrap().query, ""); session.handle(Input::Ctrl('w')); session.handle(Input::Char(' ')); session.handle(Input::Char('o')); assert_eq!( session.editor().file_picker().unwrap().view, crate::files::FileView::Tree ); assert!(session.editor().file_picker().unwrap().searching); session.handle(Input::Char('x')); session.handle(Input::Ctrl('h')); assert_eq!(session.editor().file_picker().unwrap().query, "x"); session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().searching); assert!(session.editor().file_picker().unwrap().focused); assert_eq!(session.editor().file_picker().unwrap().query, "x"); session.handle(Input::Ctrl('t')); assert_eq!( session.editor().file_picker().unwrap().view, crate::files::FileView::List ); session.handle(Input::Escape); assert!(session.editor().file_picker().is_none()); session.handle(Input::Char('i')); session.handle(Input::Char('x')); session.handle(Input::Ctrl('h')); assert_eq!(session.editor().lines(), &["xalpha"]); session.handle(Input::Backspace); assert_eq!(session.editor().lines(), &["alpha"]); } #[test] fn invalid_explorer_preferences_preserve_defaults_and_valid_bindings() { let fixture = Fixture::new(); fixture.file("buffer.txt", "abc"); let mut session = fixture.open( r#" Vamp.configure do |config| config.file_explorer_side = "diagonal" config.file_explorer_view = "grid" config.file_picker_view = "grid" config.bind "n", config.core.right end "#, ); assert_eq!( session.editor().file_explorer_side(), crate::files::DockSide::Right ); assert_eq!( session.editor().file_explorer_view(), crate::files::FileView::Tree ); session.handle(Input::Char('n')); assert_eq!( session.editor().cursor(), crate::editor::Cursor { row: 0, column: 1 } ); session.handle(Input::Char('i')); session.handle(Input::Char('!')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["a!bc"]); } #[test] fn diff_display_preferences_survive_reopening() { use crate::{diff::ViewMode, panes::SplitAxis}; let fixture = Fixture::new(); fixture.file("buffer.txt", "abc"); for (mode, orientation, expected_mode, expected_axis) in [ ( "unified", "horizontal", ViewMode::Unified, SplitAxis::Horizontal, ), ( "split", "horizontal", ViewMode::Split, SplitAxis::Horizontal, ), ("split", "vertical", ViewMode::Split, SplitAxis::Vertical), ] { let mut session = fixture.open(&format!( r#"Vamp.configure do |config| config.diff_view_mode = "{mode}" config.diff_split_orientation = "{orientation}" config.bind "n", config.core.right end"# )); session.handle(Input::Char('n')); assert_eq!(session.editor().cursor().column, 1); for _ in 0..2 { session.execute("diff"); let diff = session.editor().diff().unwrap(); assert_eq!(diff.view_mode, expected_mode); assert_eq!(diff.split_axis, expected_axis); session.execute("diff.close"); assert!(session.editor().diff().is_none()); assert_eq!(session.editor().lines(), &["abc"]); } } } #[test] fn no_config_uses_space_leader_for_palette_and_write() { let fixture = Fixture::new(); let buffer = fixture.file("buffer.txt", "base"); let mut session = EditorSession::open(Some(buffer.clone()), vec!["text".into()], None).unwrap(); session.handle(Input::Char(' ')); assert_eq!(session.editor().sequence_pending(), Some("Space")); session.handle(Input::Char(' ')); assert!(session.editor().palette().is_some()); assert!(session.editor().sequence_pending().is_none()); session.handle(Input::Escape); session.handle(Input::Char('i')); session.handle(Input::Char('!')); session.handle(Input::Escape); session.handle(Input::Char(' ')); session.handle(Input::Char('w')); assert_eq!(fs::read_to_string(buffer).unwrap(), "!base"); assert!(!session.editor().dirty()); } #[test] fn ruby_leader_sequences_share_one_config_and_plugin_host_across_buffers() { let fixture = Fixture::new(); fixture.file("buffer.txt", "alpha"); let second = fixture.file("second.txt", "beta"); fixture.file( "shared.rb", r##" Vamp.plugin "shared", requires: ["text"] calls = 0 Vamp.command("stamp") do |editor, _| calls += 1 editor.insert "#{calls}:#{File.basename(editor.path)}:" end Vamp.command("fail") { |_, _| raise "shared host failure" } "##, ); let mut session = fixture.open( r#" File.open("loads.txt", "a") { |file| file.puts "loaded" } Vamp.configure do |config| config.leader = "," config.plugin "shared.rb" config.bind "leader z n", "stamp" config.bind "leader z f", "fail" end "#, ); session.handle(Input::Char(',')); session.handle(Input::Char('z')); assert_eq!(session.editor().sequence_pending(), Some(", z")); assert_eq!(session.editor().lines(), &["alpha"]); session.handle(Input::Char('n')); assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); assert!(session.editor().sequence_pending().is_none()); session.handle(Input::Char(',')); session.handle(Input::Char('z')); session.execute(&format!("edit {}", second.display())); assert!(session.editor().sequence_pending().is_none()); for key in [',', 'z', 'n'] { session.handle(Input::Char(key)); } assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); for key in [',', 'b', 'p'] { session.handle(Input::Char(key)); } assert_eq!(session.editor().buffer_id(), 1); session.handle(Input::Home); for key in [',', 'z', 'n'] { session.handle(Input::Char(key)); } assert_eq!( session.editor().lines(), &["3:buffer.txt:1:buffer.txt:alpha"] ); assert_eq!( fs::read_to_string(fixture.0.join("loads.txt")).unwrap(), "loaded\n" ); for key in [',', 'z', 'f'] { session.handle(Input::Char(key)); } assert!( session .editor() .message() .contains("Plugin shared disabled:") ); for key in [',', 'z', 'n'] { session.handle(Input::Char(key)); } assert_eq!( session.editor().lines(), &["3:buffer.txt:1:buffer.txt:alpha"] ); session.handle(Input::Ctrl('p')); assert!( !session .editor() .palette() .unwrap() .entries .iter() .any(|entry| entry.owner == "shared") ); } #[test] fn ruby_disabled_leader_suffix_and_cancellation_keep_counts_and_selections_safe() { let fixture = Fixture::new(); fixture.file("buffer.txt", "abcdef"); fixture.healthy(); let mut session = fixture.open( r#" Vamp.configure do |config| config.leader = "," config.plugin "healthy.rb" config.bind "leader z n", "healthy" config.bind "leader z x", nil config.bind "leader z h", config.core.left, mode: config.modes.visual end "#, ); for last in ['x', 'd'] { for key in ['3', ',', 'z', last] { session.handle(Input::Char(key)); } assert!(session.editor().sequence_pending().is_none()); assert_eq!(session.editor().lines(), &["abcdef"]); session.handle(Input::Char('l')); } assert_eq!(session.editor().cursor().column, 2); for key in ['3', ',', 'z'] { session.handle(Input::Char(key)); } session.handle(Input::Paste("n:q!\n".into())); assert!(session.editor().sequence_pending().is_none()); session.handle(Input::Char('l')); assert_eq!(session.editor().cursor().column, 3); assert_eq!(session.editor().lines(), &["abcdef"]); session.handle(Input::Char('v')); let selected = session.editor().selections(); for key in ['3', ',', 'z'] { session.handle(Input::Char(key)); } session.handle(Input::Escape); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); assert_eq!(session.editor().selections(), selected); assert!(session.editor().sequence_pending().is_none()); session.handle(Input::Char('h')); assert_eq!(session.editor().cursor().column, 2); assert_eq!(session.editor().lines(), &["abcdef"]); assert!(!session.editor().should_quit()); } #[test] fn invalid_backend_leader_retains_space_and_installs_valid_config_bindings() { for key in ["not-a-key", "esc", "7", "i"] { let fixture = Fixture::new(); fixture.file("buffer.txt", "base"); fixture.healthy(); let mut session = fixture.open(&format!( r#" Vamp.configure do |config| config.leader = "{key}" config.plugin "healthy.rb" config.bind "ctrl+f", config.core.right config.bind "leader z n", "healthy" end "#, )); assert!( session .editor() .message() .contains(&format!("leader {key:?}:")) ); assert!(!session.editor().message().contains("config disabled")); session.handle(Input::Ctrl('f')); assert_eq!(session.editor().cursor().column, 1); session.handle(Input::Char(' ')); assert_eq!(session.editor().sequence_pending(), Some("Space")); session.handle(Input::Char('z')); session.handle(Input::Char('n')); assert_eq!(session.editor().lines(), &["healthy "]); } } #[test] fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() { let fixture = Fixture::new(); let first = fixture.file("buffer.txt", "alpha"); let second = fixture.file("second.txt", "beta"); fixture.file( "shared.rb", r##" Vamp.plugin "shared", requires: ["text"] calls = 0 Vamp.command("stamp") do |editor, _| calls += 1 editor.insert "#{calls}:#{File.basename(editor.path)}:" end Vamp.command("fail") { |_, _| raise "shared host failure" } "##, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "shared.rb" config.bind "g", "stamp" end "#, ); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); session.execute(&format!("edit {}", second.display())); assert_eq!(session.editor().buffer_id(), 2); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); session.execute("bdelete"); assert_eq!(session.editor().buffer_id(), 2); assert_eq!(session.editor().buffer_count(), 2); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["beta"]); session.execute("q"); assert!(!session.editor().should_quit()); session.execute("wq"); assert!(!session.editor().should_quit()); assert_eq!(fs::read_to_string(&first).unwrap(), "alpha"); assert_eq!(fs::read_to_string(&second).unwrap(), "beta"); session.execute("buffer 1"); assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["alpha"]); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); session.execute("w"); assert_eq!(fs::read_to_string(&first).unwrap(), "3:buffer.txt:alpha"); session.execute("bnext"); assert_eq!(session.editor().lines(), &["beta"]); session.execute("fail"); assert!(session.editor().message().contains("disabled")); session.execute("buffer 1"); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); session.execute("q"); assert!(session.editor().should_quit()); } #[test] fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() { let fixture = Fixture::new(); fixture.file("buffer.txt", "base"); fixture.file( "palette.rb", r#" Vamp.plugin "palette_fixture", requires: ["text"] Vamp.command("palette_insert") { |editor, args| editor.insert args } Vamp.command("palette_fail") { |_, _| raise "palette fixture failure" } "#, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "palette.rb" config.bind "ctrl+p", nil config.bind "ctrl+f", config.core.palette end "#, ); session.handle(Input::Ctrl('p')); assert!(session.editor().palette().is_none()); session.handle(Input::Ctrl('f')); for ch in "palette_insert".chars() { session.handle(Input::Char(ch)); } assert_eq!( session.editor().palette().unwrap().entries[0].owner, "palette_fixture" ); session.handle(Input::Enter); assert_eq!(session.editor().mode(), crate::editor::Mode::Command); assert_eq!(session.editor().command_line(), "palette_insert "); assert_eq!(session.editor().lines(), &["base"]); for ch in "界!".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Enter); assert_eq!(session.editor().lines(), &["界!base"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["base"]); session.handle(Input::Ctrl('f')); for ch in "palette_fail".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Enter); session.handle(Input::Enter); assert!(session.editor().message().contains("disabled")); session.handle(Input::Ctrl('f')); assert!( !session .editor() .palette() .unwrap() .entries .iter() .any(|entry| entry.owner == "palette_fixture") ); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["base"]); } #[test] fn configured_linewise_change_and_paste_preserve_rows_and_one_replacement_history() { let fixture = Fixture::new(); fixture.file("buffer.txt", " alpha\n界beta\ntail"); let mut session = fixture.open( r#" Vamp.configure do |config| config.clipboard_mode = "separate" config.bind "V", nil config.bind "s", config.core.select_linewise config.bind "s", config.core.select_linewise, mode: config.modes.visual config.bind "r", config.core.change, mode: config.modes.visual end "#, ); for key in "llsjrnew".chars() { session.handle(Input::Char(key)); } session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["new", "tail"]); session.handle(Input::Char('p')); assert_eq!( session.editor().lines(), &["new", " alpha", "界beta", "tail"] ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["new", "tail"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[" alpha", "界beta", "tail"]); session.handle(Input::Char('U')); assert_eq!(session.editor().lines(), &["new", "tail"]); } #[test] fn configured_selection_actions_edit_together_and_invoke_plugins_once() { let fixture = Fixture::new(); fixture.file("buffer.txt", " alpha\nb\n gamma"); fixture.file( "once.rb", r#" Vamp.plugin "once", requires: ["text"] Vamp.command("once") { |editor, _| editor.insert "!" } "#, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "once.rb" config.bind "r", config.core.select_lines, mode: config.modes.visual config.bind "z", config.core.clone_below config.bind "f", "once" end "#, ); session.handle(Input::Char('v')); session.handle(Input::Char('j')); session.handle(Input::Char('j')); session.handle(Input::Char('r')); assert_eq!(session.editor().selection_count(), 3); session.handle(Input::Char('A')); session.handle(Input::Char('?')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &[" alpha?", "b?", " gamma?"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); assert_eq!(session.editor().selection_count(), 3); session.handle(Input::Char('f')); assert_eq!(session.editor().selection_count(), 1); assert_eq!( session .editor() .lines() .iter() .map(|line| line.matches('!').count()) .sum::(), 1 ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); assert_eq!(session.editor().selection_count(), 3); } #[test] fn startup_rejection_installs_no_commands_and_keeps_other_plugins_and_core() { let fixture = Fixture::new(); fixture.healthy(); fixture.file( "graphics.rb", r#" Vamp.plugin "graphics", requires: ["graphics"] Vamp.command("draw") { |editor, args| editor.insert "incompatible" } "#, ); fixture.file( "collision.rb", r#" Vamp.plugin "collision", requires: ["text"] Vamp.command("leaked") { |editor, args| editor.insert "collision" } Vamp.command("q") { |editor, args| editor.insert "override" } "#, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "graphics.rb" config.plugin "collision.rb" config.plugin "missing.rb" config.plugin "healthy.rb" config.bind "g", "healthy" end "#, ); for command in ["draw", "leaked"] { session.execute(command); assert_eq!(session.editor().lines(), &[String::new()]); } session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["healthy "]); session.execute("w"); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "healthy " ); session.execute("q"); assert!(session.editor().should_quit()); } #[test] fn rejected_effects_discard_entire_transaction_and_retire_commands_and_bindings() { let fixture = Fixture::new(); fixture.healthy(); fixture.file( "invalid.rb", r#" Vamp.plugin "invalid", requires: ["text"] Vamp.command "invalid" do |editor, args| editor.insert "partial" editor.message "\e" end Vamp.command("sibling") { |editor, args| editor.insert "must be retired too" } "#, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "invalid.rb" config.plugin "healthy.rb" config.bind "g", "invalid" end "#, ); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &[String::new()]); assert!(!session.editor().dirty()); assert!( session .editor() .message() .contains("Plugin invalid disabled: Unsupported control character") ); let message = session.editor().message().to_owned(); session.handle(Input::Char('g')); assert_eq!(session.editor().message(), message); for command in ["invalid", "sibling"] { session.execute(command); assert_eq!( session.editor().message(), format!("Unknown command: {command}") ); } session.execute("healthy survivor"); assert_eq!(session.editor().lines(), &["healthy survivor"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[String::new()]); session.handle(Input::Char('u')); assert_eq!(session.editor().message(), "Nothing to undo"); } #[test] fn callback_crash_and_timeout_retire_plugin_without_losing_core_editing() { for failure in ["raise 'callback failed'", "Process.exit!(7)", "sleep 30"] { let fixture = Fixture::new(); fixture.healthy(); fixture.file( "failing.rb", &format!( r#" Vamp.plugin "failing", requires: ["text"] Vamp.command "fail" do |editor, args| editor.insert "never commit" {failure} end "# ), ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "failing.rb" config.plugin "healthy.rb" config.bind "g", "fail" end "#, ); session.execute("fail"); assert_eq!(session.editor().lines(), &[String::new()]); assert!( session .editor() .message() .contains("Plugin failing disabled:") ); session.execute("fail"); assert_eq!(session.editor().message(), "Unknown command: fail"); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &[String::new()]); session.execute("healthy survivor"); session.execute("w"); session.handle(Input::Char('i')); session.handle(Input::Char('x')); session.handle(Input::Escape); session.execute("q"); assert!(!session.editor().should_quit()); session.execute("wq"); assert!(session.editor().should_quit()); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "xhealthy survivor" ); } } #[test] fn configured_bindings_preserve_modal_editing_history_and_literal_paste() { let fixture = Fixture::new(); fixture.healthy(); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "healthy.rb" config.bind "n", config.core.insert_first_nonblank config.bind "i", nil config.bind "ctrl+r", config.core.redo config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "esc", nil, mode: config.modes.insert config.bind "!", config.core.newline, mode: config.modes.insert config.bind "shift+x", nil, mode: config.modes.insert config.bind ":", nil config.bind "space", config.core.command config.bind "enter", nil, mode: config.modes.command config.bind "ctrl+x", config.core.submit, mode: config.modes.command config.bind "ctrl+c", config.core.normal, mode: config.modes.command config.bind "ctrl+h", config.core.backspace, mode: config.modes.command config.bind "g", config.core.undo config.bind "g", "healthy" config.bind "n", config.core.newline config.bind "unknown", config.core.undo config.bind "ctrl+f", "healthy", mode: config.modes.insert end "#, ); session.handle(Input::Char('i')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('n')); for ch in "界Xab!".chars() { session.handle(Input::Char(ch)); } assert_eq!(session.editor().lines(), &["界ab", ""]); session.handle(Input::Escape); assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); session.handle(Input::Paste("X!".into())); assert_eq!(session.editor().lines(), &["界ab", "X!"]); session.handle(Input::Ctrl('C')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["界ab", ""]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[String::new()]); session.handle(Input::Ctrl('r')); session.handle(Input::Ctrl('r')); assert_eq!(session.editor().lines(), &["界ab", "X!"]); session.handle(Input::Char(':')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char(' ')); assert_eq!(session.editor().mode(), crate::editor::Mode::Command); assert!(session.editor().sequence_pending().is_none()); for ch in "w界".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Backspace); assert_eq!(session.editor().command_line(), "w"); session.handle(Input::Enter); assert_eq!(session.editor().mode(), crate::editor::Mode::Command); session.handle(Input::Ctrl('x')); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "界ab\nX!" ); session.handle(Input::Char(' ')); session.handle(Input::Char('q')); session.handle(Input::Ctrl('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(session.editor().command_line().is_empty()); assert!(!session.editor().should_quit()); session.handle(Input::Char('g')); assert_eq!(session.editor().lines(), &["healthy "]); session.handle(Input::Char(' ')); session.handle(Input::Char('q')); session.handle(Input::Ctrl('x')); assert!(!session.editor().should_quit()); session.handle(Input::Char(' ')); session.handle(Input::Char('q')); session.handle(Input::Char('!')); session.handle(Input::Ctrl('x')); assert!(session.editor().should_quit()); } #[test] fn default_dot_replaces_fresh_words_and_each_repeat_has_one_undo() { let fixture = Fixture::new(); let buffer = fixture.file("buffer.txt", "old elephant zebra"); let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap(); session.editor.set_clipboard_mode("separate").unwrap(); for key in ['v', 'e', 'c'] { session.handle(Input::Char(key)); } for ch in "item".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["item elephant zebra"]); for key in ['w', '3', '.'] { session.handle(Input::Char(key)); } assert_eq!(session.editor().lines(), &["item item zebra"]); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["item elephant zebra"]); session.handle(Input::Home); for key in ['w', 'w', 'v', 'e', '.'] { session.handle(Input::Char(key)); } assert_eq!(session.editor().lines(), &["item elephant item"]); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["item elephant zebra"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["old elephant zebra"]); assert!(!session.editor().dirty()); } #[test] fn typed_repeat_remaps_use_resolved_motions_and_current_visual_ranges() { let fixture = Fixture::new(); fixture.file("buffer.txt", "old enormous tail end"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind ".", nil config.bind ".", nil, mode: config.modes.visual config.bind "r", config.core.repeat_last_edit config.bind "r", config.core.repeat_last_edit, mode: config.modes.visual config.bind "e", config.core.left, mode: config.modes.visual config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual end "#, ); session.handle(Input::Char('v')); session.handle(Input::Ctrl('f')); session.handle(Input::Char('c')); for ch in "item".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Escape); session.handle(Input::Char('w')); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["item enormous tail end"]); session.handle(Input::Char('r')); assert_eq!(session.editor().lines(), &["item item tail end"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["item enormous tail end"]); session.handle(Input::Home); for key in ['w', 'v', '2'] { session.handle(Input::Char(key)); } session.handle(Input::Ctrl('f')); session.handle(Input::Char('.')); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); assert_eq!(session.editor().lines(), &["item enormous tail end"]); session.handle(Input::Char('r')); assert_eq!(session.editor().lines(), &["item item end"]); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["item enormous tail end"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["old enormous tail end"]); } #[test] fn repeat_recipe_crosses_buffers_but_pending_insert_and_undo_stay_per_buffer() { let fixture = Fixture::new(); fixture.file("buffer.txt", "alpha"); let second = fixture.file("second.txt", "beta"); let mut session = fixture.open(""); session.handle(Input::Char('i')); for ch in "one".chars() { session.handle(Input::Char(ch)); } session.execute(&format!("edit {}", second.display())); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!(session.editor().lines(), &["beta"]); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["onebeta"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["beta"]); session.execute("buffer 1"); assert_eq!(session.editor().lines(), &["onealpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["alpha"]); session.handle(Input::Char('U')); assert_eq!(session.editor().lines(), &["onealpha"]); session.execute("buffer 2"); assert_eq!(session.editor().lines(), &["beta"]); session.handle(Input::Char('U')); assert_eq!(session.editor().lines(), &["onebeta"]); session.handle(Input::Home); session.handle(Input::Char('i')); for ch in "two".chars() { session.handle(Input::Char(ch)); } session.execute("buffer 1"); session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["twoonealpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["onealpha"]); session.execute("buffer 2"); assert_eq!(session.editor().lines(), &["twoonebeta"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["onebeta"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["beta"]); } #[test] fn plugin_effects_commit_pending_typing_without_replacing_or_reinvoking_native_recipe() { let fixture = Fixture::new(); fixture.file("buffer.txt", "base"); fixture.file( "effects.rb", r##" Vamp.plugin "effects", requires: ["text"] calls = 0 Vamp.command("effect") do |editor, args| calls += 1 File.open("calls.txt", "a") { |file| file.puts "#{calls} #{args}" } case args when "replace" editor.replace "plugin" when "insert" editor.insert "?" end editor.message "calls=#{calls}" end "##, ); let mut session = fixture.open( r#" Vamp.configure do |config| config.plugin "effects.rb" end "#, ); session.handle(Input::Char('i')); session.handle(Input::Char('!')); session.execute("effect replace"); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!(session.editor().lines(), &["plugin"]); assert_eq!(session.editor().message(), "calls=1"); session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["!plugin"]); assert_eq!( fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), "1 replace\n" ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["plugin"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["!base"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["base"]); session.handle(Input::Home); session.execute("effect insert"); assert_eq!(session.editor().lines(), &["?base"]); assert_eq!(session.editor().message(), "calls=2"); session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["!?base"]); assert_eq!( fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), "1 replace\n2 insert\n" ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["?base"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["base"]); session.execute("effect message"); assert_eq!(session.editor().message(), "calls=3"); session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["!base"]); assert_eq!( fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), "1 replace\n2 insert\n3 message\n" ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["base"]); } #[test] fn explicit_save_commits_pending_change_and_repeat_does_not_save_again() { let fixture = Fixture::new(); let buffer = fixture.file("buffer.txt", "old elephant"); let mut session = fixture.open(""); for key in ['v', 'e', 'c'] { session.handle(Input::Char(key)); } for ch in "item".chars() { session.handle(Input::Char(ch)); } session.execute("w"); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); session.handle(Input::Home); session.handle(Input::Char('w')); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["item item"]); assert!(session.editor().dirty()); assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["item elephant"]); assert!(!session.editor().dirty()); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["old elephant"]); } #[test] fn dots_in_insert_and_focused_queries_are_literal_and_excluded_from_recipe() { let fixture = Fixture::new(); fixture.file("buffer.txt", "alpha"); let mut session = fixture.open( r#" Vamp.configure do |config| config.file_explorer_view = "list" config.file_picker_view = "list" config.bind "ctrl+f", config.core.file_explorer config.bind "ctrl+o", config.core.file_picker end "#, ); session.handle(Input::Char('i')); session.handle(Input::Char('X')); session.handle(Input::Escape); session.handle(Input::Home); session.handle(Input::Ctrl('f')); for ch in ".query".chars() { session.handle(Input::Char(ch)); } assert_eq!(session.editor().file_picker().unwrap().query, ".query"); assert_eq!(session.editor().lines(), &["Xalpha"]); session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().focused); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Ctrl('o')); session.handle(Input::Ctrl('u')); session.handle(Input::Char('.')); assert_eq!(session.editor().file_picker().unwrap().query, "."); assert_eq!(session.editor().lines(), &["Xalpha"]); session.handle(Input::Escape); assert!(session.editor().file_picker().is_none()); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["XXalpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["Xalpha"]); session.handle(Input::Home); session.handle(Input::Char('i')); session.handle(Input::Char('.')); session.handle(Input::Ctrl('p')); assert!( session .editor() .palette() .unwrap() .entries .iter() .any(|entry| entry.name == "core.repeat_last_edit") ); session.handle(Input::Char('.')); assert_eq!(session.editor().palette().unwrap().query, "."); assert_eq!(session.editor().lines(), &[".Xalpha"]); session.handle(Input::Escape); session.handle(Input::Char('!')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &[".!Xalpha"]); for prompt in [':', '/'] { session.handle(Input::Char(prompt)); session.handle(Input::Char('.')); assert_eq!(session.editor().command_line(), "."); assert_eq!(session.editor().lines(), &[".!Xalpha"]); session.handle(Input::Escape); } session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &[".!.!Xalpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[".!Xalpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["Xalpha"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["alpha"]); } #[test] fn repeated_clipboard_paste_uses_captured_text_after_a_different_yank() { let fixture = Fixture::new(); fixture.file("buffer.txt", "one two three"); let mut session = fixture.open(""); for key in ['v', 'e', 'y'] { session.handle(Input::Char(key)); } session.handle(Input::Home); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["oneone two three"]); for key in ['w', 'v', 'e', 'y'] { session.handle(Input::Char(key)); } session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["oneoneone two three"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["oneone two three"]); session.handle(Input::Home); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["twooneone two three"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["oneone two three"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["one two three"]); } #[test] fn literal_paste_repeats_without_dispatch_and_preserves_native_input_groups() { let fixture = Fixture::new(); fixture.file("buffer.txt", "seed"); let mut session = fixture.open(""); session.handle(Input::Char('i')); session.handle(Input::Char('a')); session.handle(Input::Paste(".:q!\r\n界".into())); session.handle(Input::Char('b')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["a.:q!", "界bseed"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["a.:q!", "界seed"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["aseed"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["seed"]); session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &["bseed"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["seed"]); session.handle(Input::Home); session.handle(Input::Char('i')); session.handle(Input::Paste(".:q!\r\n界".into())); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); session.handle(Input::Home); session.handle(Input::Char('.')); assert_eq!(session.editor().lines(), &[".:q!", ".:q!", "界界seed"]); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(!session.editor().should_quit()); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["seed"]); } #[test] fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() { let fixture = Fixture::new(); fixture.file("buffer.txt", "abc DEF tail"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "c", nil, mode: config.modes.visual config.bind "ctrl+r", config.core.change, mode: config.modes.visual config.bind "ctrl+v", config.core.select config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual end "#, ); session.handle(Input::Char('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(!session.editor().dirty()); session.handle(Input::Ctrl('v')); session.handle(Input::Ctrl('f')); session.handle(Input::Char('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); assert_eq!(session.editor().lines(), &["abc DEF tail"]); session.handle(Input::Ctrl('r')); assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); assert_eq!(session.editor().cursor().column, 0); assert_eq!(session.editor().lines(), &[" DEF tail"]); for ch in "cZ".chars() { session.handle(Input::Char(ch)); } session.execute("w"); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "cZ DEF tail" ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["abc DEF tail"]); session.handle(Input::Char('P')); assert_eq!(session.editor().lines(), &["ababcc DEF tail"]); } #[test] fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() { let fixture = Fixture::new(); fixture.file("buffer.txt", "one two"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "v", nil config.bind "ctrl+v", config.core.select config.bind "ctrl+c", config.core.copy, mode: config.modes.visual config.bind "ctrl+x", config.core.cut, mode: config.modes.visual config.bind "ctrl+g", config.core.normal, mode: config.modes.visual config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual config.bind "ctrl+b", config.core.paste_before config.bind "ctrl+p", config.core.paste_after end "#, ); session.handle(Input::Char('v')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Ctrl('v')); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); session.handle(Input::Ctrl('f')); assert_eq!(session.editor().selections()[0].end.column, 3); session.handle(Input::Ctrl('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(!session.editor().dirty()); session.handle(Input::Ctrl('p')); assert_eq!(session.editor().lines(), &["oonene two"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["one two"]); session.handle(Input::Ctrl('v')); session.handle(Input::Ctrl('f')); session.handle(Input::Ctrl('x')); assert_eq!(session.editor().lines(), &[" two"]); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Ctrl('b')); assert_eq!(session.editor().lines(), &["one two"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[" two"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["one two"]); assert!(!session.editor().dirty()); session.handle(Input::Ctrl('v')); session.handle(Input::Ctrl('g')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(session.editor().selections().is_empty()); } #[test] fn typed_word_motions_remap_keys_and_keep_insert_typing_in_one_undo_group() { let fixture = Fixture::new(); fixture.file("buffer.txt", "foo...bar baz"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "w", nil config.bind "ctrl+f", config.core.word_next config.bind "ctrl+p", config.core.word_previous config.bind "ctrl+e", config.core.word_end config.bind "ctrl+a", config.core.big_word_next config.bind "ctrl+z", config.core.big_word_previous config.bind "ctrl+t", config.core.big_word_end config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert end "#, ); session.handle(Input::Char('w')); assert_eq!(session.editor().cursor().column, 0); for (key, column) in [ ('f', 3), ('e', 5), ('f', 6), ('p', 3), ('z', 0), ('a', 10), ('t', 12), ('z', 10), ('z', 0), ] { session.handle(Input::Ctrl(key)); assert_eq!(session.editor().cursor().column, column, "{key}"); } assert!(!session.editor().dirty()); session.handle(Input::Char('i')); for ch in "wbeWBE".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Ctrl('b')); assert_eq!(session.editor().cursor().column, 0); session.handle(Input::Char('X')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), &["XwbeWBEfoo...bar baz"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["foo...bar baz"]); assert!(!session.editor().dirty()); } #[test] fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() { let fixture = Fixture::new(); fixture.file("buffer.txt", "cat\ncat\ncat"); let mut session = fixture.open( r#" Vamp.configure do |config| config.bind "ctrl+f", config.core.search config.bind "ctrl+n", config.core.search_next config.bind "ctrl+p", config.core.search_previous config.bind "ctrl+x", config.core.submit, mode: config.modes.search config.bind "ctrl+c", config.core.normal, mode: config.modes.search config.bind "ctrl+h", config.core.backspace, mode: config.modes.search config.bind "enter", nil, mode: config.modes.search config.bind "/", nil end "#, ); session.handle(Input::Char('/')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); session.handle(Input::Ctrl('f')); assert_eq!(session.editor().mode(), crate::editor::Mode::Search); for ch in "cat界".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Backspace); session.handle(Input::Enter); assert_eq!(session.editor().mode(), crate::editor::Mode::Search); session.handle(Input::Ctrl('x')); assert_eq!(session.editor().cursor().row, 1); session.handle(Input::Ctrl('n')); assert_eq!(session.editor().cursor().row, 2); session.handle(Input::Ctrl('p')); assert_eq!(session.editor().cursor().row, 1); session.handle(Input::Ctrl('f')); session.handle(Input::Char('q')); session.handle(Input::Ctrl('c')); session.handle(Input::Ctrl('n')); assert_eq!(session.editor().cursor().row, 2); assert!(!session.editor().dirty()); assert!(!session.editor().should_quit()); } #[test] fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() { let fixture = Fixture::new(); for (expression, error) in [ ("config.core.reod", "NoMethodError"), ("config.modes.insret", "NoMethodError"), ("config.leader = 7", "leader key must be a String"), ("config.leader = nil", "leader key must be a String"), (r#"config.leader = """#, "leader key must not be empty"), ( r#"config.diff_view_mode = "invalid""#, "diff view mode must be split or unified", ), ( r#"config.diff_split_orientation = "invalid""#, "diff split orientation must be vertical or horizontal", ), ( r#"config.diff_split_orientation = "left_right""#, "diff split orientation must be vertical or horizontal", ), ( r#"config.diff_split_orientation = "above_below""#, "diff split orientation must be vertical or horizontal", ), ( "config.diff_view_mode = 7", "diff view mode must be a String", ), ( "config.diff_view_mode = nil", "diff view mode must be a String", ), ( r#"config.diff_view_mode = """#, "diff view mode must not be empty", ), ( "config.diff_split_orientation = 7", "diff split orientation must be a String", ), ( "config.diff_split_orientation = nil", "diff split orientation must be a String", ), ( r#"config.diff_split_orientation = """#, "diff split orientation must not be empty", ), ( "config.file_explorer_side = 7", "file explorer side must be a String", ), ( "config.file_explorer_side = nil", "file explorer side must be a String", ), ( r#"config.file_explorer_side = """#, "file explorer side must not be empty", ), ( "config.file_picker_view = 7", "file picker view must be a String", ), ( "config.file_picker_view = nil", "file picker view must be a String", ), ( r#"config.file_picker_view = """#, "file picker view must not be empty", ), ( "config.file_explorer_view = 7", "file explorer view must be a String", ), ( "config.file_explorer_view = nil", "file explorer view must be a String", ), ( r#"config.file_explorer_view = """#, "file explorer view must not be empty", ), ( "config.clipboard_mode = 7", "clipboard mode must be a String", ), ( "config.clipboard_mode = nil", "clipboard mode must be a String", ), ( r#"config.clipboard_mode = """#, "clipboard mode must not be empty", ), (r#"config.bind "n", "core.insert""#, "TypeError"), (r#"config.bind "n", nil, mode: "insert""#, "TypeError"), (r#"config.bind "n", config.modes.insert"#, "TypeError"), ( r#"config.bind "n", nil, mode: config.core.insert"#, "TypeError", ), (r#"config.core.redo.name = "core.undo""#, "FrozenError"), ( r#"config.core.redo.name.replace("core.undo")"#, "FrozenError", ), (r#"config.modes.insert.name = "normal""#, "FrozenError"), ( r#"config.modes.insert.name.replace("normal")"#, "FrozenError", ), ] { let mut session = fixture.open(&format!( "Vamp.configure do |config|\n config.leader = \",\"\n config.bind \"i\", nil\n {expression}\nend" )); assert!( session .editor() .startup_diagnostic() .unwrap() .lines .iter() .any(|line| line.contains(error)), "{expression}" ); session.handle(Input::Enter); session.handle(Input::Char('i')); session.handle(Input::Char('界')); session.handle(Input::Escape); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!(session.editor().lines(), &["界"], "{expression}"); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &[String::new()], "{expression}"); session.handle(Input::Char(' ')); assert_eq!( session.editor().sequence_pending(), Some("Space"), "{expression}" ); session.handle(Input::Char(' ')); assert!(session.editor().palette().is_some(), "{expression}"); session.handle(Input::Escape); } } #[test] fn rejected_config_diagnostic_persists_until_dismissed_and_default_editing_survives() { for (config, reason, dismiss) in [ ("raise 'broken config'", "broken config", Input::Enter), ( "Vamp.configure { |config| config.lsp['unsupported-language'] = true }", "unsupported-language", Input::Escape, ), ] { let fixture = Fixture::new(); let mut session = fixture.open(config); let diagnostic = session .editor() .startup_diagnostic() .expect("config failure must be visible"); let text = diagnostic.lines.join("\n"); assert!(text.contains(&fixture.0.join("config.rb").display().to_string())); assert!(text.contains(reason)); session.editor.set_message("unrelated background message"); session.poll(); for input in [ Input::Char('i'), Input::Paste("accidental edit".into()), Input::Ctrl('p'), ] { session.handle(input); } assert_eq!( session .editor() .startup_diagnostic() .unwrap() .lines .join("\n"), text ); assert_eq!(session.editor().mode(), Mode::Normal); assert_eq!(session.editor().lines(), &[""]); session.handle(dismiss); assert!(session.editor().startup_diagnostic().is_none()); session.handle(Input::Char('i')); session.handle(Input::Char('x')); session.handle(Input::Escape); session.execute("wq"); assert!(session.editor().should_quit()); assert_eq!( fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), "x" ); } } fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) { // Process-I/O timeout, not a date-dependent assertion. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); loop { session.poll(); if ready(session) { return; } assert!( std::time::Instant::now() < deadline, "plugin did not settle: {}", session.editor().message() ); std::thread::sleep(std::time::Duration::from_millis(2)); } } fn barrier(session: &mut EditorSession, message: &str) { session.execute(&format!("fixture_barrier {message}")); drain_until(session, |session| session.editor().message() == message); } fn diagnostic_effect(id: usize, revision: u64, message: &str) -> serde_json::Value { serde_json::json!({ "type": "diagnostics", "buffer_id": id, "revision": revision, "items": [{"start": {"row": 0, "column": 0}, "end": {"row": 0, "column": 1}, "severity": "warning", "message": message}] }) } fn publish(session: &mut EditorSession, effects: Vec) { session.execute(&format!( "fixture_publish {}", serde_json::json!({"effects": effects}) )); } fn hold(session: &mut EditorSession, gate: &Path, effects: Vec) { session.execute(&format!( "fixture_hold {}", serde_json::json!({"gate": gate, "effects": effects}) )); } fn release(session: &mut EditorSession, gate: &Path) { fs::write(gate, "").unwrap(); let delivered = format!("delivered {}", gate.display()); drain_until(session, |session| session.editor().message() == delivered); } #[test] fn startup_chrome_installs_named_values_without_editing_the_document() { let fixture = Fixture::new(); fixture.file("buffer.txt", "source"); let session = fixture.open( r#" Vamp.configure do |config| config.bar.top = {left: [:mode, :language], right: [:battery]} config.bar.bottom = {left: [:tabs], center: [:workspace]} config.labels = {battery: "bat 38%", workspace: "tools\n"} end "#, ); assert_eq!(session.editor.label("battery"), "bat 38%"); assert_eq!(session.editor.label("workspace"), "tools�"); assert_eq!(session.editor.top_bar_config().left, ["mode", "language"]); assert_eq!(session.editor.top_bar_config().right, ["battery"]); assert_eq!(session.editor.bottom_bar_config().left, ["tabs"]); assert_eq!(session.editor.bottom_bar_config().center, ["workspace"]); assert_eq!(session.editor.lines(), ["source"]); assert_eq!(session.editor.text_revision(), 0); assert!(!session.editor.dirty()); } #[test] fn asynchronous_result_labels_outlive_the_source_without_applying_stale_edits() { let fixture = Fixture::new(); let target = fixture.file("other.txt", ""); let mut session = EditorSession::open(None, vec![], None).unwrap(); let source = InvocationSource { buffer_id: session.editor.buffer_id(), pane_id: session.editor.pane_id(), revision: session.editor.text_revision(), }; session.editor.execute("tab.new"); session .editor .execute(&format!("edit {}", target.display())); assert_ne!(session.editor.buffer_id(), source.buffer_id); session.editor.handle(Input::Char('i')); let message = session.editor.message().to_owned(); for (id, effects) in [ ( 1, vec![ Effect::Insert("stale".into()), Effect::Message("stale message".into()), Effect::Label { name: "battery".into(), text: "bat 38%".into(), }, ], ), ( 2, vec![ Effect::Label { name: "battery".into(), text: "must not leak".into(), }, Effect::Label { name: "bad-name".into(), text: "invalid".into(), }, ], ), ] { session .requests .entry("fixture".into()) .or_default() .pending .insert(id, source); let result = session.apply_response("fixture", id, effects); if id == 1 { assert!(result.unwrap()); } else { assert!(result.is_err()); } assert_eq!(session.editor.label("battery"), "bat 38%"); assert_eq!(session.editor.lines(), [""]); assert_eq!(session.editor.message(), message); assert_eq!(session.editor.mode(), Mode::Insert); assert_eq!(session.editor.text_revision(), 0); assert!(!session.editor.dirty()); } session.editor.handle(Input::Char('x')); session.editor.handle(Input::Escape); session.editor.handle(Input::Char('u')); assert_eq!(session.editor.lines(), [""]); assert_eq!(session.editor.label("battery"), "bat 38%"); } #[test] fn named_labels_survive_tab_switches_and_remain_atomic_in_readonly_views() { let fixture = Fixture::new(); let mut session = fixture.asynchronous(None); publish( &mut session, vec![serde_json::json!({ "type": "label", "name": "battery", "text": "bat 37%" })], ); drain_until(&mut session, |session| { session.editor().label("battery") == "bat 37%" }); let gate = fixture.0.join("label.gate"); hold( &mut session, &gate, vec![serde_json::json!({ "type": "label", "name": "battery", "text": "bat 38%" })], ); barrier(&mut session, "label request received"); session.execute("tab.new"); release(&mut session, &gate); assert_eq!(session.editor().label("battery"), "bat 38%"); session.execute("tab.previous"); assert_eq!(session.editor().label("battery"), "bat 38%"); let update = |text: &str| PluginUpdate { request_id: None, effects: vec![UpdateEffect::Label { name: "battery".into(), text: text.into(), }], }; assert!( !session .apply_update("generic_fixture", update("bat 38%")) .unwrap() ); let rejected = PluginUpdate { request_id: None, effects: vec![ UpdateEffect::Label { name: "battery".into(), text: "must not leak".into(), }, UpdateEffect::SemanticTokens { buffer_id: session.editor().buffer_id(), revision: session.editor().text_revision(), tokens: None, }, ], }; assert!(session.apply_update("generic_fixture", rejected).is_err()); assert_eq!(session.editor().label("battery"), "bat 38%"); let message = session.editor.message().to_owned(); for effects in [ vec![ UpdateEffect::Message { text: "must not leak".into(), }, UpdateEffect::Label { name: "battery".into(), text: "changed".into(), }, UpdateEffect::Label { name: "bad-name".into(), text: "invalid".into(), }, ], vec![ UpdateEffect::Label { name: "battery".into(), text: "x".repeat(2 * 1024 * 1024), }, UpdateEffect::Label { name: "workspace".into(), text: "x".repeat(2 * 1024 * 1024), }, ], ] { assert!( session .apply_update( "generic_fixture", PluginUpdate { request_id: None, effects } ) .is_err() ); assert_eq!(session.editor.label("battery"), "bat 38%"); assert_eq!(session.editor.label("workspace"), ""); assert_eq!(session.editor.message(), message); } session.editor.execute("diff"); assert!(session.editor.mutation_blocked()); assert!( session .apply_update("generic_fixture", update("bat\t39%\n")) .unwrap() ); assert_eq!(session.editor().label("battery"), "bat�39%�"); assert_eq!(session.editor().lines(), [""]); assert!(session.apply_update("generic_fixture", update("")).unwrap()); assert_eq!(session.editor().label("battery"), ""); } #[test] fn idle_poll_refreshes_clean_and_parked_files_on_the_interval_but_keeps_dirty_text() { let fixture = Fixture::new(); let active = fixture.file("buffer.txt", "old"); let parked = fixture.file("parked.txt", "parked"); let mut session = fixture.open(""); session.execute(&format!("edit {}", parked.display())); session.execute("buffer 1"); let now = session.last_file_refresh; let revision = session.editor().text_revision(); fs::write(&active, "external replacement").unwrap(); fs::write(&parked, "external parked replacement").unwrap(); session.poll_at(now + Duration::from_millis(249)); assert_eq!(session.editor().lines(), &["old"]); session.dispatch(Invocation { plugin: "core".into(), command: "lsp.definition".into(), args: String::new(), }); assert!(session.requests["core"].latest.is_some()); assert!(session.poll_at(now + Duration::from_millis(250))); assert!(session.requests["core"].latest.is_none()); assert_eq!(session.editor().lines(), &["external replacement"]); assert!(session.editor().text_revision() > revision); let documents: Vec<_> = session.editor().documents().collect(); assert_eq!(documents[1].lines, ["external parked replacement"]); session.handle(Input::Char('x')); let dirty = session.editor().lines().to_vec(); let revision = session.editor().text_revision(); fs::write(&active, "second external replacement").unwrap(); session.poll_at(now + Duration::from_millis(500)); assert_eq!(session.editor().lines(), dirty.as_slice()); assert_eq!(session.editor().text_revision(), revision); assert_eq!( fs::read_to_string(&active).unwrap(), "second external replacement" ); } #[test] fn clean_external_reload_notifies_async_plugin_with_change_without_save() { let fixture = Fixture::new(); let file = fixture.file("buffer.txt", "old"); let mut session = fixture.asynchronous(Some(file.clone())); barrier(&mut session, "opened"); let now = session.last_file_refresh; let revision = session.editor().text_revision(); fs::write(&file, "updated external text").unwrap(); assert!(session.poll_at(now + Duration::from_millis(250))); assert_eq!(session.editor().lines(), &["updated external text"]); let updated_revision = session.editor().text_revision(); assert!(updated_revision > revision); assert_eq!( session.documents[&session.editor().buffer_id()] .snapshot .revision, updated_revision ); barrier(&mut session, "reload delivered"); let events = fixture.events(); let changes: Vec<_> = events .iter() .filter(|event| event["event"] == "change") .collect(); assert_eq!(changes.len(), 1); assert_eq!(changes[0]["document"]["text"], "updated external text"); assert_eq!(changes[0]["document"]["revision"], updated_revision); assert!(!events.iter().any(|event| event["event"] == "save")); } #[test] fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() { let fixture = Fixture::new(); let mut session = fixture.asynchronous(None); barrier(&mut session, "initial"); let initial = fixture.events(); assert_eq!(initial[0]["event"], "open"); assert_eq!(initial[0]["document"]["id"], 1); assert_eq!(initial[0]["document"]["revision"], 0); assert_eq!(initial[0]["document"]["path"], serde_json::Value::Null); assert_eq!(initial[0]["document"]["text"], ""); assert_eq!( initial[0]["document"]["root"], session.editor().project_root().to_string_lossy().as_ref() ); session.handle(Input::Char('i')); for input in [ Input::Char('😀'), Input::Char('e'), Input::Char('\u{301}'), Input::Enter, ] { session.handle(input); } session.handle(Input::Escape); let revision = session.editor().text_revision(); session.handle(Input::Char('h')); session.handle(Input::Char('l')); let renamed = fixture.0.join("saved unicode 😀.txt"); session.execute(&format!("w {}", renamed.display())); session.execute("w"); session.execute(&format!("w {}", fixture.0.join("missing/failed").display())); barrier(&mut session, "saved"); assert_eq!(session.editor().text_revision(), revision); assert_eq!( session.editor().documents().next().unwrap().save_revision, 2 ); assert_eq!(fs::read_to_string(&renamed).unwrap(), "😀e\u{301}\n"); let events = fixture.events(); assert_eq!( events .iter() .filter(|event| event["event"] == "change") .count(), revision as usize ); let kinds: Vec<_> = events .iter() .filter_map(|event| event["event"].as_str()) .collect(); assert_eq!(&kinds[kinds.len() - 4..], ["close", "open", "save", "save"]); let reopened = events .iter() .find(|event| event["event"] == "open" && !event["document"]["path"].is_null()) .unwrap(); assert_eq!(reopened["document"]["revision"], revision); assert_eq!(reopened["document"]["text"], "😀e\u{301}\n"); let target = fixture.file("target.txt", "target"); session.execute(&format!("edit {}", target.display())); session.handle(Input::Char('x')); session.execute("buffer 1"); let documents: Vec<_> = session.editor().documents().collect(); assert_eq!( documents .iter() .map(|document| document.id) .collect::>(), [1, 2] ); assert_eq!(documents[1].lines, ["arget"]); assert_eq!(documents[1].revision, 1); assert_eq!(documents[1].save_revision, 0); session.execute("bdelete! 2"); barrier(&mut session, "closed"); let events = fixture.events(); let closed = events .iter() .find(|event| event["event"] == "close" && event["document"]["id"] == 2) .unwrap(); assert_eq!(closed["document"]["text"], "arget"); assert_eq!(closed["document"]["revision"], 1); session.execute("q!"); barrier(&mut session, "quit flushed"); assert!(session.editor().should_quit()); let events = fixture.events(); let final_close = events .iter() .rev() .find(|event| event["event"] == "close") .unwrap(); assert_eq!(final_close["document"]["id"], 1); assert_eq!( final_close["document"]["path"], renamed.to_string_lossy().as_ref() ); } #[test] fn async_annotations_and_messages_preserve_insert_group_and_ignore_stale_closed_sources() { let fixture = Fixture::new(); let file = fixture.file("buffer.txt", "abc"); let mut session = fixture.asynchronous(Some(file)); let gate = fixture.0.join("annotations-ready"); hold(&mut session, &gate, vec![diagnostic_effect(1, 1, "live")]); session.handle(Input::Char('i')); session.handle(Input::Char('x')); release(&mut session, &gate); assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); assert_eq!( session .editor() .diagnostic_at_cursor() .map(|diagnostic| diagnostic.message.as_str()), None ); assert_eq!(session.editor().diagnostics().count(), 0); session.handle(Input::Char('y')); assert_eq!(session.editor().diagnostics().count(), 0); session.handle(Input::Escape); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), ["abc"]); let current = session.editor().text_revision(); let mut stale = diagnostic_effect(1, 0, "stale"); stale["items"][0]["end"]["row"] = serde_json::json!(999); publish( &mut session, vec![ stale, diagnostic_effect(999, current, "closed"), serde_json::json!({"type":"message","text":"stale checked"}), ], ); drain_until(&mut session, |session| { session.editor().message() == "stale checked" }); assert_eq!(session.editor().diagnostics().count(), 0); assert!(session.plugins.contains_key("generic_fixture")); let target = fixture.file("closed.txt", "closed"); session.execute(&format!("edit {}", target.display())); let gate = fixture.0.join("closed-ready"); hold( &mut session, &gate, vec![diagnostic_effect(2, 0, "late close")], ); session.execute("bdelete 2"); release(&mut session, &gate); assert_eq!(session.editor().buffer_id(), 1); assert_eq!(session.editor().diagnostics().count(), 0); assert!(session.plugins.contains_key("generic_fixture")); } #[test] fn invalid_annotation_batches_retire_owner_and_clear_hidden_annotations_only() { let fixture = Fixture::new(); let file = fixture.file("buffer.txt", "source"); let target = fixture.file("target.txt", "target"); let mut session = fixture.asynchronous(Some(file)); session.execute(&format!("edit {}", target.display())); session.execute("buffer 1"); publish( &mut session, vec![ diagnostic_effect(1, 0, "source"), diagnostic_effect(2, 0, "hidden"), ], ); drain_until(&mut session, |session| { session.editor().diagnostics().count() == 1 }); let other = crate::plugin::Diagnostic { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 1 }, severity: crate::plugin::DiagnosticSeverity::Hint, message: "other owner".into(), }; session .editor .replace_diagnostics("other", 1, 0, vec![other]) .unwrap(); let mut invalid = diagnostic_effect(1, 0, "bad range"); invalid["items"][0]["end"]["column"] = serde_json::json!(999); publish( &mut session, vec![diagnostic_effect(2, 0, "must not commit"), invalid], ); drain_until(&mut session, |session| { !session.plugins.contains_key("generic_fixture") }); assert!( session .editor() .message() .contains("Diagnostic position is outside") ); assert_eq!(session.editor().diagnostics().count(), 1); assert_eq!( session.editor().diagnostic_at_cursor().unwrap().message, "other owner" ); session.execute("buffer 2"); assert_eq!(session.editor().diagnostics().count(), 0); session.execute("fixture_publish {}"); assert!(session.editor().message().contains("Unknown command")); session.handle(Input::Char('x')); assert_eq!(session.editor().lines(), ["arget"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), ["target"]); } fn navigation_effect(path: &Path) -> serde_json::Value { serde_json::json!({"type":"navigate", "path":path, "row":0, "column":1}) } #[test] fn async_navigation_reuses_dirty_target_and_reports_open_failure_without_retiring() { let fixture = Fixture::new(); let source = fixture.file("buffer.txt", "source"); let target = fixture.file("dirty 😀 target.txt", "a😀e\u{301}"); let mut session = fixture.asynchronous(Some(source)); session.execute(&format!("edit {}", target.display())); session.handle(Input::Char('i')); session.handle(Input::Char('!')); session.handle(Input::Escape); session.execute("buffer 1"); publish(&mut session, vec![navigation_effect(&target)]); drain_until(&mut session, |session| session.editor().buffer_id() == 2); assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]); assert_eq!( session.editor().cursor(), crate::editor::Cursor { row: 0, column: 1 } ); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert_eq!(session.editor().buffer_count(), 2); assert_eq!(fs::read_to_string(&target).unwrap(), "a😀e\u{301}"); let gate = fixture.0.join("missing-ready"); hold( &mut session, &gate, vec![navigation_effect(&fixture.0.join("missing"))], ); fs::write(&gate, "").unwrap(); drain_until(&mut session, |session| { session .requests .get("generic_fixture") .is_some_and(|requests| requests.pending.is_empty()) && session.editor().message().starts_with("delivered ") }); assert_eq!(session.editor().buffer_id(), 2); assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]); assert!(session.plugins.contains_key("generic_fixture")); // A standalone failure message remains visible when no later plugin message overwrites it. publish( &mut session, vec![navigation_effect(&fixture.0.join("missing"))], ); drain_until(&mut session, |session| { session.editor().message().starts_with("Navigation failed:") }); assert_eq!(session.editor().buffer_id(), 2); assert_eq!(session.editor().buffer_count(), 2); assert!(session.plugins.contains_key("generic_fixture")); } #[test] fn async_navigation_drops_superseded_changed_or_inactive_source_replies() { for guard in ["superseded", "changed", "inactive", "closed"] { let fixture = Fixture::new(); let source = fixture.file("buffer.txt", "source"); let target = fixture.file("target.txt", "target"); let mut session = fixture.asynchronous(Some(source)); let gate = fixture.0.join("navigation-ready"); hold(&mut session, &gate, vec![navigation_effect(&target)]); match guard { "superseded" => barrier(&mut session, "new request"), "changed" => { session.handle(Input::Char('i')); session.handle(Input::Char('x')); } "inactive" => { let other = fixture.file("other.txt", "other"); session.execute(&format!("edit {}", other.display())); } "closed" => session.execute("bdelete 1"), _ => unreachable!(), } let active = session.editor().buffer_id(); let text = session.editor().context().text; release(&mut session, &gate); assert_eq!(session.editor().buffer_id(), active, "{guard}"); assert_eq!(session.editor().context().text, text, "{guard}"); assert!(session.plugins.contains_key("generic_fixture"), "{guard}"); assert!( !session .editor() .buffers() .any(|buffer| buffer.path == Some(target.as_path())), "{guard}" ); let invokes: Vec<_> = fixture .events() .into_iter() .filter(|event| event["invoke"] == "fixture_hold") .collect(); assert_eq!(invokes.len(), 1); assert_eq!(invokes[0]["buffer_id"], 1); assert_eq!(invokes[0]["revision"], 0); assert_eq!(invokes[0]["row"], 1); assert_eq!(invokes[0]["column"], 1); } } #[test] fn stale_async_text_response_and_dead_host_never_break_native_typing() { let fixture = Fixture::new(); let file = fixture.file("buffer.txt", "source"); let mut session = fixture.asynchronous(Some(file)); session.execute("fixture_replace BAD"); session.handle(Input::Char('i')); session.handle(Input::Char('x')); drain_until(&mut session, |session| { session .requests .get("generic_fixture") .is_some_and(|requests| requests.pending.is_empty()) }); assert_eq!(session.editor().lines(), ["xsource"]); assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); session.handle(Input::Char('y')); session.handle(Input::Escape); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), ["source"]); let revision = session.editor().text_revision(); publish( &mut session, vec![diagnostic_effect(1, revision, "before crash")], ); drain_until(&mut session, |session| { session.editor().diagnostics().count() == 1 }); session.execute("fixture_crash"); session.handle(Input::Char('i')); session.handle(Input::Char('z')); drain_until(&mut session, |session| { !session.plugins.contains_key("generic_fixture") }); assert_eq!(session.editor().diagnostics().count(), 0); assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); session.handle(Input::Char('q')); session.handle(Input::Escape); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), ["source"]); assert!(!session.editor().should_quit()); session.execute("plugins"); assert!(session.editor().message().contains("core [core]")); } #[test] fn async_navigation_invokes_once_at_primary_with_multiple_carets() { let fixture = Fixture::new(); let source = fixture.file("buffer.txt", "abc\nabc"); let target = fixture.file("target.txt", "target"); let mut session = fixture.asynchronous(Some(source)); session.handle(Input::Char('l')); session.handle(Input::Ctrl('e')); assert_eq!(session.editor().selection_count(), 2); assert_eq!( session.editor().cursor(), crate::editor::Cursor { row: 1, column: 1 } ); let gate = fixture.0.join("primary-ready"); hold(&mut session, &gate, vec![navigation_effect(&target)]); release(&mut session, &gate); assert_eq!(session.editor().path(), Some(target.as_path())); assert_eq!(session.editor().selection_count(), 1); let invokes: Vec<_> = fixture .events() .into_iter() .filter(|event| event["invoke"] == "fixture_hold") .collect(); assert_eq!(invokes.len(), 1); assert_eq!(invokes[0]["row"], 2); 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.execute("diff.close"); session.execute("ping"); drain_until(&mut session, |session| { session.editor().message() == "still alive" }); assert_eq!(session.editor().context().text, "original"); } #[test] fn async_responses_edit_only_current_bound_working_after_and_refresh_projection() { use crate::diff::{ Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget, }; let fixture = Fixture::new(); fixture.file("buffer.txt", "base"); let mut session = fixture.open(""); session.editor.execute("diff"); let (id, _) = session.editor.take_diff_request().unwrap(); let revision = |target, label: &str| Revision { target, label: label.into(), description: String::new(), category: Category::Commits, }; session.editor.apply_diff_result( id, Ok(DiffOutcome::Catalog(Catalog { kind: RepoKind::Git, notice: None, entries: vec![ revision(RevisionTarget::Commit("base".into()), "base"), revision(RevisionTarget::Working, "Working"), ], })), ); for input in [Input::Down, Input::Enter, Input::Enter] { session.editor.handle(input); } let (id, _) = session.editor.take_diff_request().unwrap(); session.editor.apply_diff_result( id, Ok(DiffOutcome::Comparison(Comparison { root: fixture.0.canonicalize().unwrap(), before: "base".into(), after: "Working".into(), editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(), files: vec![ChangedFile { path: PathBuf::from("buffer.txt"), old_path: None, status: FileStatus::Modified, loaded: true, before: vec!["base".into()], after: vec!["base".into()], before_eof: false, after_eof: false, hunks: Vec::new(), notice: None, }], })), ); session.editor.handle(Input::BackTab); let source = InvocationSource { buffer_id: session.editor.buffer_id(), pane_id: session.editor.pane_id(), revision: session.editor.text_revision(), }; session .requests .entry("fixture".into()) .or_default() .pending .insert(1, source); assert!( session .apply_response("fixture", 1, vec![Effect::Insert("accepted".into())]) .unwrap() ); assert_eq!(session.editor.lines(), ["acceptedbase"]); let diff = session.editor.diff().unwrap(); assert_eq!(diff.live_revision, Some(session.editor.text_revision())); assert_eq!( diff.comparison.as_ref().unwrap().files[0].after, session.editor.lines() ); session .requests .get_mut("fixture") .unwrap() .pending .insert(2, source); assert!( !session .apply_response("fixture", 2, vec![Effect::Insert("stale".into())]) .unwrap() ); session.editor.scroll_diff(Focus::Before, false); let readonly = InvocationSource { buffer_id: session.editor.buffer_id(), pane_id: session.editor.pane_id(), revision: session.editor.text_revision(), }; session .requests .get_mut("fixture") .unwrap() .pending .insert(3, readonly); assert!( !session .apply_response("fixture", 3, vec![Effect::Insert("readonly".into())]) .unwrap() ); assert_eq!(session.editor.lines(), ["base"]); assert!( session .editor .documents() .any(|document| document.lines == ["acceptedbase"]) ); session .requests .get_mut("fixture") .unwrap() .pending .insert(4, readonly); assert!( session .apply_response("fixture", 4, vec![Effect::Message("allowed".into())]) .unwrap() ); assert_eq!(session.editor.message(), "allowed"); session.editor.execute("diff.close"); assert!(session.editor.dirty()); } }