4896 lines
188 KiB
Rust
4896 lines
188 KiB
Rust
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<DiffOutcome, String>);
|
|
|
|
struct DiffWorker {
|
|
requests: Option<Sender<(u64, DiffOperation)>>,
|
|
results: Option<Receiver<DiffResult>>,
|
|
cancelled: Arc<AtomicBool>,
|
|
stopped: Arc<AtomicBool>,
|
|
cancelled_request: Arc<AtomicU64>,
|
|
thread: Option<JoinHandle<()>>,
|
|
pending: Option<u64>,
|
|
}
|
|
|
|
impl DiffWorker {
|
|
fn start(root: PathBuf) -> Result<Self, String> {
|
|
let (requests, incoming) = mpsc::channel::<(u64, DiffOperation)>();
|
|
let (outgoing, results) = mpsc::sync_channel(1);
|
|
let cancelled = Arc::new(AtomicBool::new(false));
|
|
let stopped = Arc::new(AtomicBool::new(false));
|
|
let cancelled_request = Arc::new(AtomicU64::new(0));
|
|
let cancelled_id = Arc::clone(&cancelled_request);
|
|
let stop = Arc::clone(&stopped);
|
|
let cancellation = Arc::clone(&cancelled);
|
|
let thread = thread::Builder::new()
|
|
.name("vamp-diff".into())
|
|
.spawn(move || {
|
|
let mut repository = None;
|
|
while let Ok((id, operation)) = incoming.recv() {
|
|
if stop.load(Ordering::Acquire) {
|
|
break;
|
|
}
|
|
cancellation.store(false, Ordering::Release);
|
|
if stop.load(Ordering::Acquire) {
|
|
break;
|
|
}
|
|
if cancelled_id.load(Ordering::Acquire) >= id {
|
|
continue;
|
|
}
|
|
let result = (|| {
|
|
if repository.is_none() {
|
|
repository = Some(Repository::open(&root, Arc::clone(&cancellation))?);
|
|
}
|
|
let repository = repository.as_mut().expect("repository opened");
|
|
match operation {
|
|
DiffOperation::Discover => {
|
|
repository.catalog().map(DiffOutcome::Catalog)
|
|
}
|
|
DiffOperation::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),
|
|
pending: None,
|
|
})
|
|
}
|
|
|
|
fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> {
|
|
if let Some(previous) = self.pending.take() {
|
|
self.cancelled_request.store(previous, Ordering::Release);
|
|
self.cancelled.store(true, Ordering::Release);
|
|
}
|
|
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.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<u64, InvocationSource>,
|
|
latest: Option<(u64, InvocationSource)>,
|
|
}
|
|
|
|
struct GutterSource {
|
|
buffer: usize,
|
|
revision: u64,
|
|
save_revision: u64,
|
|
path: PathBuf,
|
|
dirty: bool,
|
|
contents: Arc<str>,
|
|
}
|
|
|
|
/// 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<String, RubyPlugin>,
|
|
documents: BTreeMap<usize, TrackedDocument>,
|
|
requests: BTreeMap<String, AsyncRequests>,
|
|
lsp: Lsp,
|
|
lsp_request_id: u64,
|
|
lsp_source_cursor: Option<Cursor>,
|
|
lsp_source_mode: Mode,
|
|
lsp_source_view: Option<(usize, Option<u64>)>,
|
|
pending_hover: bool,
|
|
pending_completion: bool,
|
|
hover_owner: Option<String>,
|
|
diff_workers: BTreeMap<usize, DiffWorker>,
|
|
blame_worker: Option<BlameWorker>,
|
|
blame_source: Option<BlameSource>,
|
|
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
|
|
blame_request_id: u64,
|
|
gutter_worker: Option<crate::gutter::Worker>,
|
|
gutter_sources: Vec<GutterSource>,
|
|
gutter_request_id: u64,
|
|
gutter_pending: bool,
|
|
last_gutter_refresh: Option<Instant>,
|
|
picker_source: Option<(usize, PathBuf)>,
|
|
battery: Option<crate::battery::Battery>,
|
|
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<PathBuf>,
|
|
capabilities: Vec<String>,
|
|
config: Option<&Path>,
|
|
) -> io::Result<Self> {
|
|
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_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 !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 worker.pending == Some(id) {
|
|
worker.pending = None;
|
|
}
|
|
changed |= self.editor.apply_diff_result(id, result);
|
|
}
|
|
Err(mpsc::TryRecvError::Empty) => 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<u64>,
|
|
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<u64>,
|
|
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<u64>, 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<bool, String> {
|
|
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<Effect>,
|
|
) -> Result<bool, String> {
|
|
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<Option<Vec<Option<Arc<crate::blame::LineBlame>>>>, 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 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<PathBuf>) -> 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<serde_json::Value> {
|
|
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::<usize>(),
|
|
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<serde_json::Value>) {
|
|
session.execute(&format!(
|
|
"fixture_publish {}",
|
|
serde_json::json!({"effects": effects})
|
|
));
|
|
}
|
|
|
|
fn hold(session: &mut EditorSession, gate: &Path, effects: Vec<serde_json::Value>) {
|
|
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::<Vec<_>>(),
|
|
[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());
|
|
}
|
|
}
|