feat: add Git and Jujutsu gutter indicators

This commit is contained in:
2026-10-13 17:44:29 +02:00
parent e84d70463f
commit 55cdee1a13
14 changed files with 1392 additions and 32 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
- [x] noticed that `d<count>w` also removes the newline and as such merges lines. this is not intended behavior and should be fixed. `dw` should not affect other lines (and neither should `db`).
- [x] add `c<count>w` and `c<count>b` actions mimicking the `d` equivalent, but also entering insert mode.
- [ ] add git/jj gutter info:
- [x] add git/jj gutter info:
- fat, vertical, colored bar on inner side of gutter closer to gutter than to text (default)
- colored symbols:
- new: `+`
+3 -1
View File
@@ -84,7 +84,9 @@ Already-ready mouse reports share a redraw: at most 64 reports are applied in th
Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys.
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add one marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column.
Repository gutter work runs off the render/editor thread. Revision/path-validated marker vectors belong to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode.
## Syntax caches
+5
View File
@@ -14,6 +14,7 @@ Vamp.configure do |config|
config.leader = "space"
config.clipboard_mode = "system"
config.git_blame = true # false disables current-line attribution
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
config.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
@@ -48,6 +49,10 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
`config.git_gutter` defaults to `"bar"`: a fat `▌` beside each changed line number, followed by a blank before the text. `"symbols"` uses `+` / `-` / `~` instead. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` disables repository indicators, not line numbers. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
Indicators compare the current editor text (including unsaved edits) against Git `HEAD`, or the parent `@-` in Git-backed jj repositories, rather than against the last save or Git index. Line endings use the editor's LF normalization. They refresh after edits/saves, on focus regain, and once per second for repository changes. Historical snapshots do not receive live indicators. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository, ignored, binary, symlink, and unsupported files have no indicators; neither do jj working changes with multiple parents. Both colocated and non-colocated Git-backed jj repositories are supported. Files above 16 MiB report an error and clear their indicators; request batches are limited to 64 MiB or 1024 files.
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
+2
View File
@@ -55,6 +55,8 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
+4
View File
@@ -4,6 +4,10 @@
Default controls and editing behavior. For custom bindings, see [configuration](configuration.md); for language-server commands, see [native LSP](configuration.md#native-lsp).
Ordinary documents show repository changes beside their hybrid line numbers by default: green `▌` for additions, red for removals, and yellow for modifications. [Configure `git_gutter`](configuration.md) to use `+` / `-` / `~`, colored numbers, or no change indicators. Bar/symbol gutters keep the order **number, marker, blank, text**; number-only modes use no extra column. Horizontal scrolling never moves indicators, and wrapped continuation rows do not repeat them.
The baseline is Git `HEAD` or Git-backed jj's parent `@-`, so indicators include unsaved edits and remain after saving until the repository baseline changes. A deletion marks the next surviving source row; at EOF it marks the last editor row, including the final empty row after a trailing newline. An empty file anchors removals on row 0. Replacements mark paired rows modified and extra current rows added. Historical snapshots do not show live changes. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository/ignored/binary/symlink files, files over 16 MiB, and jj changes with multiple parents have no indicators.
## Default editing keys
| Mode | Keys |
+21 -8
View File
@@ -154,11 +154,20 @@ impl Drop for Temporary {
}
// Same process-group cancellation as repository comparisons, with a smaller output/time budget.
fn run(
mut command: Command,
pub(crate) fn run(
command: Command,
input: &[u8],
cancelled: &dyn Fn() -> bool,
) -> Result<Option<Vec<u8>>, String> {
let (code, bytes) = run_status(command, input, cancelled)?;
Ok((code == Some(0)).then_some(bytes))
}
pub(crate) fn run_status(
mut command: Command,
input: &[u8],
cancelled: &dyn Fn() -> bool,
) -> Result<(Option<i32>, Vec<u8>), String> {
if cancelled() {
return Err("Blame cancelled".into());
}
@@ -239,20 +248,24 @@ fn run(
return Err(error.into());
}
let bytes = bytes.map_err(str::to_owned)?;
if !status.unwrap().success() {
return Ok(None);
}
let status = status.unwrap();
if status.success() {
written.map_err(|_| "Cannot supply blame contents")?;
Ok(Some(bytes))
}
Ok((status.code(), bytes))
})
}
}
fn git(root: &Path) -> Command {
pub(crate) fn git(root: &Path) -> Command {
let mut command = git_without_literal_pathspecs(root);
command.arg("--literal-pathspecs");
command
}
pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
let mut command = Command::new("git");
command
.args([
"--no-pager",
"--literal-pathspecs",
"-c",
"core.hooksPath=/dev/null",
"-c",
+73 -1
View File
@@ -1511,6 +1511,12 @@ impl FileStamp {
}
}
struct GutterChanges {
revision: u64,
path: PathBuf,
lines: Vec<Option<crate::gutter::Change>>,
}
struct Buffer {
id: usize,
diff_snapshot: Option<(u64, usize, Focus)>,
@@ -1524,6 +1530,7 @@ struct Buffer {
save_revision: u64,
diagnostics: BTreeMap<String, AnnotationSet>,
semantic_tokens: Option<Arc<[SemanticToken]>>,
gutter_changes: Option<GutterChanges>,
view: ViewState,
path: Option<PathBuf>,
identity: Option<PathBuf>,
@@ -1580,6 +1587,7 @@ impl Buffer {
save_revision: 0,
diagnostics: BTreeMap::new(),
semantic_tokens: None,
gutter_changes: None,
view: ViewState {
targets: vec![Target::caret(Cursor::default())],
..ViewState::empty()
@@ -1755,6 +1763,7 @@ pub struct Editor {
battery_charge: Option<u8>,
battery_label: String,
git_blame: bool,
gutter_style: crate::gutter::GutterStyle,
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
lsp_names: Vec<String>,
lsp_label: String,
@@ -1855,6 +1864,7 @@ impl Editor {
battery_charge: None,
battery_label: String::new(),
git_blame: true,
gutter_style: crate::gutter::GutterStyle::default(),
line_blame: None,
lsp_names: Vec::new(),
lsp_label: String::new(),
@@ -4884,6 +4894,68 @@ impl Editor {
pub fn battery_label(&self) -> &str {
&self.battery_label
}
pub(crate) fn gutter_style(&self) -> crate::gutter::GutterStyle {
self.gutter_style
}
pub(crate) fn set_gutter_style(&mut self, style: crate::gutter::GutterStyle) {
self.gutter_style = style;
}
pub(crate) fn gutter_change(&self, row: usize) -> Option<crate::gutter::Change> {
if self.active.diff_snapshot.is_some() || self.active.reload_error.is_some() {
return None;
}
let changes = self.active.gutter_changes.as_ref()?;
if changes.revision != self.active.text_revision
|| Some(changes.path.as_path()) != self.path()
{
return None;
}
changes.lines.get(row).copied().flatten()
}
pub(crate) fn set_gutter_changes(
&mut self,
buffer: usize,
revision: u64,
path: &Path,
changes: Vec<Option<crate::gutter::Change>>,
) -> bool {
let buffer = if buffer == self.active.id {
&mut self.active
} else if let Some(buffer) = self.parked.get_mut(&buffer) {
buffer
} else {
return false;
};
if buffer.diff_snapshot.is_some()
|| buffer.text_revision != revision
|| buffer.path.as_deref() != Some(path)
|| changes.len() > buffer.lines.len()
{
return false;
}
if buffer
.gutter_changes
.as_ref()
.is_some_and(|old| old.revision == revision && old.path == path && old.lines == changes)
{
return false;
}
let visible = changes.iter().any(Option::is_some)
|| buffer
.gutter_changes
.as_ref()
.is_some_and(|old| old.lines.iter().any(Option::is_some));
buffer.gutter_changes = Some(GutterChanges {
revision,
path: path.to_path_buf(),
lines: changes,
});
visible
}
pub fn git_blame(&self) -> bool {
self.git_blame
}
@@ -10305,7 +10377,7 @@ fn validate_text(text: &str) -> Result<(), String> {
}
}
fn normalize_newlines(text: &str) -> Cow<'_, str> {
pub(crate) fn normalize_newlines(text: &str) -> Cow<'_, str> {
if !text.contains('\r') {
return Cow::Borrowed(text);
}
+797
View File
@@ -0,0 +1,797 @@
//! Read-only source-line changes against Git HEAD or a single Jujutsu @- parent.
use crate::{
blame::{git, git_without_literal_pathspecs, run, run_status},
diff::line_hunks,
editor::normalize_newlines,
repository::{jj_command, jj_file_pattern, safe_path, store_for_jj},
};
use serde::Deserialize;
use std::{
fs, io,
path::{Path, PathBuf},
process::Command,
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread::{self, JoinHandle},
};
const FILE_LIMIT: usize = 16 * 1024 * 1024;
const BATCH_LIMIT: usize = 64 * 1024 * 1024;
const FILE_COUNT_LIMIT: usize = 1024;
type Files = Vec<(usize, PathBuf, Arc<str>)>;
type FileResult = Result<Vec<Option<Change>>, String>;
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
type Request = (u64, u64, Files);
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GutterStyle {
#[default]
Bar,
Symbols,
LineNumbers,
None,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub(crate) enum Change {
Added,
Removed,
Modified,
}
#[derive(Default)]
struct State {
request: Option<Request>,
result: Option<(u64, ResultBatch)>,
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
wake: Condvar,
generation: AtomicU64,
stopped: AtomicBool,
}
pub(crate) struct Worker {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
impl Worker {
pub(crate) fn start() -> io::Result<Self> {
let shared = Arc::new(Shared::default());
let worker = Arc::clone(&shared);
let thread = thread::Builder::new()
.name("vamp-gutter".into())
.spawn(move || {
loop {
let Ok(mut state) = worker.state.lock() else {
return;
};
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
let Ok(next) = worker.wake.wait(state) else {
return;
};
state = next;
}
if worker.stopped.load(Ordering::Acquire) {
return;
}
let (generation, id, files) = state.request.take().unwrap();
drop(state);
let cancelled = || {
worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation
};
let mut results = Vec::with_capacity(files.len());
for (buffer, path, contents) in files {
if cancelled() {
break;
}
results.push((buffer, read_file(&path, &contents, &cancelled)));
}
let Ok(mut state) = worker.state.lock() else {
return;
};
if !cancelled() {
state.result = Some((id, results));
}
}
})?;
Ok(Self {
shared,
thread: Some(thread),
})
}
pub(crate) fn request(&mut self, id: u64, files: Files) -> io::Result<()> {
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
size.checked_add(text.len())?
.checked_add(path.as_os_str().len())
});
if files.len() > FILE_COUNT_LIMIT || size.is_none_or(|size| size > BATCH_LIMIT) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Gutter batch exceeds 64 MiB or 1024 files",
));
}
let mut state = self
.shared
.state
.lock()
.map_err(|_| io::Error::other("Gutter worker stopped"))?;
let generation = self
.shared
.generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
state.request = Some((generation, id, files));
state.result = None;
self.shared.wake.notify_one();
Ok(())
}
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch)> {
self.shared.state.lock().ok()?.result.take()
}
}
impl Drop for Worker {
fn drop(&mut self) {
let state = self.shared.state.lock();
self.shared.stopped.store(true, Ordering::Release);
self.shared.wake.notify_one();
drop(state);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result<Vec<u8>, String> {
run(command, &[], cancelled)?.ok_or_else(|| "Cannot read gutter repository metadata".into())
}
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> FileResult {
if contents.len() > FILE_LIMIT {
return Err("Gutter file exceeds 16 MiB".into());
}
if contents.as_bytes().contains(&0) {
return Ok(vec![]);
}
match fs::symlink_metadata(path) {
Ok(metadata) if !metadata.file_type().is_file() => return Ok(vec![]),
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.to_string()),
}
// Check the lexical path before canonicalizing: metadata files and symlinked parents
// must not acquire the markers of their target outside the checkout.
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| e.to_string())?
.join(path)
};
if absolute
.components()
.any(|part| part.as_os_str() == ".git" || part.as_os_str() == ".jj")
{
return Ok(vec![]);
}
let Some(root) = absolute.parent().and_then(|parent| {
parent
.ancestors()
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
}) else {
return Ok(vec![]);
};
if absolute
.ancestors()
.take_while(|part| *part != root)
.any(|part| {
fs::symlink_metadata(part).is_ok_and(|metadata| metadata.file_type().is_symlink())
})
{
return Ok(vec![]);
}
let relative = absolute.strip_prefix(root).map_err(|e| e.to_string())?;
if safe_path(relative).is_err() {
return Ok(vec![]);
}
// Resolve ancestors above the checkout, preserving a missing named-buffer tail.
let root = fs::canonicalize(root).map_err(|e| e.to_string())?;
let baseline = if root.join(".jj").exists() {
jj_baseline(&root, relative, cancelled)?
} else {
git_baseline(&root, relative, cancelled)?
};
let Some(baseline) = baseline else {
return Ok(vec![]);
};
if baseline.contains(&0) {
return Ok(vec![]);
}
let Ok(baseline) = std::str::from_utf8(&baseline) else {
return Ok(vec![]);
};
if cancelled() {
return Err("Gutter cancelled".into());
}
Ok(changes(baseline, contents))
}
fn eligible(
root: &Path,
relative: &Path,
store: Option<&Path>,
cancelled: &dyn Fn() -> bool,
) -> Result<bool, String> {
let command = |literal_pathspecs| {
let mut command = if literal_pathspecs {
git(root)
} else {
git_without_literal_pathspecs(root)
};
if let Some(store) = store {
command
.arg("--git-dir")
.arg(store)
.arg("--work-tree")
.arg(root);
}
command
};
let mut tracked = command(true);
tracked
.args(["ls-files", "--cached", "-z", "--"])
.arg(relative);
let bytes = checked(tracked, cancelled)?;
if bytes
.split(|byte| *byte == 0)
.any(|entry| entry == relative.as_os_str().as_encoded_bytes())
{
return Ok(true);
}
let mut ignored = command(false);
ignored
.args(["check-ignore", "--no-index", "-q", "--"])
.arg(relative);
match run_status(ignored, &[], cancelled)?.0 {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => Err("Cannot read gutter ignore rules".into()),
}
}
fn git_baseline(
root: &Path,
relative: &Path,
cancelled: &dyn Fn() -> bool,
) -> Result<Option<Vec<u8>>, String> {
let mut head = git(root);
head.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
let (code, head) = run_status(head, &[], cancelled)?;
if code == Some(1) {
// An unborn repository still has an index and ignore rules.
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
}
if code != Some(0) {
return Err("Cannot resolve Git gutter HEAD".into());
}
let head = std::str::from_utf8(&head)
.map_err(|_| "Invalid Git gutter HEAD")?
.trim();
let mut tree = git(root);
tree.args(["ls-tree", "-z", "--full-tree", head, "--"])
.arg(relative);
let entries = checked(tree, cancelled)?;
let Some(entry) = entries
.split(|byte| *byte == 0)
.find(|entry| !entry.is_empty())
else {
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
};
let tab = entry
.iter()
.position(|byte| *byte == b'\t')
.ok_or("Invalid Git gutter tree")?;
if &entry[tab + 1..] != relative.as_os_str().as_encoded_bytes() {
return Err("Unexpected Git gutter path".into());
}
let fields = std::str::from_utf8(&entry[..tab]).map_err(|_| "Invalid Git gutter tree")?;
let mut fields = fields.split(' ');
if !matches!(fields.next(), Some("100644" | "100755")) || fields.next() != Some("blob") {
return Ok(None);
}
let object = fields.next().ok_or("Missing Git gutter blob")?;
let mut cat = git(root);
cat.args(["cat-file", "blob", object]);
checked(cat, cancelled).map(Some)
}
fn jj_file_kind(
root: &Path,
revision: &str,
pattern: &str,
cancelled: &dyn Fn() -> bool,
) -> Result<Option<String>, String> {
let mut command = jj_command(root);
command.args([
"file",
"list",
"-r",
revision,
"-T",
"json(file_type) ++ \"\\n\"",
"--",
pattern,
]);
let bytes = checked(command, cancelled)?;
if bytes.is_empty() {
return Ok(None);
}
let kind =
serde_json::from_slice(&bytes).map_err(|_| "Invalid Jujutsu gutter file metadata")?;
Ok(Some(kind))
}
fn jj_baseline(
root: &Path,
relative: &Path,
cancelled: &dyn Fn() -> bool,
) -> Result<Option<Vec<u8>>, String> {
let Ok(store) = store_for_jj(root) else {
return Ok(None);
};
let Some(path) = relative.to_str() else {
return Ok(None);
};
let mut parents = jj_command(root);
parents.args([
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id ++ \"\\n\"",
]);
let bytes = checked(parents, cancelled)?;
let ids = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu gutter parents")?;
let mut ids = ids.lines();
let Some(parent) = ids.next() else {
return Ok(None);
};
// ponytail: no merged-parent materializer exists here; hide markers on merges
// rather than report changes against an arbitrary first parent.
if ids.next().is_some() {
return Ok(None);
}
let pattern = jj_file_pattern(path);
let baseline_kind = if parent.bytes().all(|byte| byte == b'0') {
None
} else {
jj_file_kind(root, parent, &pattern, cancelled)?
};
if let Some(kind) = baseline_kind {
if !matches!(kind.as_str(), "file" | "conflict") {
return Ok(None);
}
let mut show = jj_command(root);
show.args(["file", "show", "-r", parent, "-T", "", "--", &pattern]);
return checked(show, cancelled).map(Some);
}
if let Some(kind) = jj_file_kind(root, "@", &pattern, cancelled)? {
return Ok(matches!(kind.as_str(), "file" | "conflict").then(Vec::new));
}
Ok(eligible(root, relative, Some(&store), cancelled)?.then(Vec::new))
}
fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
fn source(text: &str) -> (Vec<String>, bool) {
(
text.split_terminator('\n').map(str::to_owned).collect(),
text.ends_with('\n'),
)
}
let before = normalize_newlines(before);
let after = normalize_newlines(after);
if before == after {
return vec![None; after.split('\n').count()];
}
let (before, before_eof) = source(&before);
let (after_lines, after_eof) = source(&after);
let mut markers = vec![None; after.split('\n').count()];
for hunk in line_hunks(&before, before_eof, &after_lines, after_eof) {
if hunk.after_len == 0 {
let anchor = hunk.after_start.min(markers.len() - 1);
// A deletion sharing a row with a replacement remains modified.
if markers[anchor].is_none() {
markers[anchor] = Some(Change::Removed);
}
} else {
for offset in 0..hunk.after_len {
markers[hunk.after_start + offset] = Some(if offset < hunk.before_len {
Change::Modified
} else {
Change::Added
});
}
}
}
markers
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::ffi::OsString;
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf);
impl Temporary {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-gutter-test-{}-{}",
std::process::id(),
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Gutter Author\"",
"--config=user.email=\"gutter@example.test\"",
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn git_fixture() -> (Temporary, PathBuf) {
let temp = Temporary::new();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
command(
&temp.0,
"git",
&["config", "user.email", "gutter@example.test"],
);
let path = temp.0.join("-literal [file]\n\t\".txt");
fs::write(&path, "first\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
(temp, path)
}
#[test]
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
use Change::*;
for (before, after, expected) in [
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\n",
"first\nsecond\n",
vec![None, None, None],
),
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\nthird\r\n",
"first\nchanged\nthird\n",
vec![None, Some(Modified), None, None],
),
(
"first\nsecond\nthird\n",
"first\rchanged\rthird\r",
vec![None, Some(Modified), None, None],
),
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
(
"old\n",
"changed\nsurplus\n",
vec![Some(Modified), Some(Added), None],
),
(
"old\nextra\nlast\n",
"changed\nlast\n",
vec![Some(Modified), None, None],
),
(
"first\nremoved\nlast\n",
"first\nlast\n",
vec![None, Some(Removed), None],
),
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
("first\nremoved", "first", vec![Some(Modified)]),
("first\nremoved", "first\n", vec![None, Some(Removed)]),
("removed\n", "", vec![Some(Removed)]),
("", "new\n", vec![Some(Added), None]),
("", "", vec![None]),
("same", "same\n", vec![Some(Modified), None]),
] {
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
}
}
#[test]
fn git_crlf_baseline_matches_normalized_editor_rows() {
let (temp, _) = git_fixture();
let path = temp.0.join("crlf");
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
command(&temp.0, "git", &["add", "--", "crlf"]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "CRLF baseline"],
);
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
vec![None, Some(Change::Modified), None, None]
);
}
#[test]
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
let (temp, path) = git_fixture();
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
let index = fs::read(temp.0.join(".git/index")).unwrap();
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
assert_eq!(
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
vec![
Some(Change::Modified),
Some(Change::Modified),
Some(Change::Modified),
None
]
);
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
fs::remove_file(&path).unwrap();
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let untracked = temp.0.join("untracked");
fs::write(&untracked, "unsaved\n").unwrap();
assert_eq!(
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
}
#[test]
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
let temp = Temporary::new();
let path = temp.0.join("new/named");
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let disk = temp.0.join("untracked");
fs::write(&disk, "disk\n").unwrap();
assert_eq!(
read_file(&disk, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
assert!(
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
.unwrap()
.is_empty()
);
assert!(
read_file(&disk, "binary\0text", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
assert!(
read_file(&temp.0.join("link"), "text\n", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
assert!(
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
.unwrap()
.is_empty()
);
}
#[test]
fn git_non_utf8_filename_is_literal() {
use std::os::unix::ffi::OsStringExt;
let (temp, _) = git_fixture();
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
let path = temp.0.join(&name);
match fs::write(&path, "old\n") {
Ok(()) => {}
// APFS rejects non-UTF-8 names before repository code can read them.
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
}
let mut add = git(&temp.0);
add.args(["add", "--"]).arg(&name);
checked(add, &|| false).unwrap();
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
assert_eq!(
read_file(&path, "changed\n", &|| false).unwrap(),
vec![Some(Change::Modified), None]
);
}
#[test]
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
for colocate in ["--colocate", "--no-colocate"] {
let temp = Temporary::new();
command(&temp.0, "jj", &["git", "init", colocate, "."]);
let name = "-literal [file]\n\t\".txt";
let path = temp.0.join(name);
fs::write(&path, "original\n").unwrap();
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
assert!(
read_file(&temp.0.join("ignored"), "text\n", &|| false)
.unwrap()
.is_empty()
);
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
command(&temp.0, "jj", &["new"]);
let operation = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let store = store_for_jj(&temp.0).unwrap();
let refs = fs::read(store.join("HEAD")).unwrap();
fs::write(&path, "disk edit\n").unwrap();
assert_eq!(
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
vec![Some(Change::Added), None, None]
);
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
assert_eq!(
command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
]
),
operation
);
let parent = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
command(&temp.0, "jj", &["new", "root()"]);
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
command(&temp.0, "jj", &["new", &parent, "@"]);
assert!(
read_file(&path, "original\n", &|| false)
.unwrap()
.is_empty()
);
}
}
#[test]
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
let (_temp, path) = git_fixture();
let mut worker = Worker::start().unwrap();
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
for id in 0..20 {
worker
.request(
id,
vec![
(5, path.clone(), "first\nchanged\nthird\n".into()),
(8, path.clone(), Arc::clone(&oversized)),
],
)
.unwrap();
}
loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 19);
assert_eq!(result[0].0, 5);
assert_eq!(
result[0].1.as_ref().unwrap(),
&vec![None, Some(Change::Modified), None, None]
);
assert_eq!(result[1].0, 8);
assert_eq!(
result[1].1.as_ref().unwrap_err(),
"Gutter file exceeds 16 MiB"
);
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(20, vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())])
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod clipboard;
pub mod diff;
pub mod editor;
pub mod files;
pub mod gutter;
pub mod lsp;
mod lsp_config;
mod markdown;
+21 -16
View File
@@ -229,7 +229,7 @@ fn git_command(store: &Path) -> Command {
.env("GIT_TERMINAL_PROMPT", "0");
cmd
}
fn jj_command(root: &Path) -> Command {
pub(crate) fn jj_command(root: &Path) -> Command {
let mut cmd = Command::new("jj");
cmd.arg("--repository")
.arg(root)
@@ -255,7 +255,7 @@ fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
))
}
}
fn safe_path(path: &Path) -> Result<(), String> {
pub(crate) fn safe_path(path: &Path) -> Result<(), String> {
if path.as_os_str().is_empty()
|| path
.components()
@@ -274,7 +274,7 @@ fn safe_path(path: &Path) -> Result<(), String> {
}
Ok(())
}
fn store_for_jj(root: &Path) -> Result<PathBuf, String> {
pub(crate) fn store_for_jj(root: &Path) -> Result<PathBuf, String> {
let mut repo = root.join(".jj/repo");
if repo.is_file() {
let pointer = fs::read(&repo).map_err(|e| e.to_string())?;
@@ -289,6 +289,23 @@ fn store_for_jj(root: &Path) -> Result<PathBuf, String> {
.map_err(|e| e.to_string())
}
pub(crate) fn jj_file_pattern(path: &str) -> String {
let mut pattern = String::from("root-file:\"");
for ch in path.chars() {
match ch {
'\\' => pattern.push_str("\\\\"),
'"' => pattern.push_str("\\\""),
'\0'..='\u{1f}' | '\u{7f}' => {
use std::fmt::Write as _;
write!(&mut pattern, "\\x{:02x}", ch as u32).unwrap();
}
_ => pattern.push(ch),
}
}
pattern.push('"');
pattern
}
struct Remote {
name: String,
url: String,
@@ -872,19 +889,7 @@ impl Repository {
};
let bytes = if matches!(kind.as_str(), "file" | "symlink" | "conflict") {
// Use an exact fileset and jj's own conflict materialization.
let mut pattern = String::from("root-file:\"");
for ch in path.chars() {
match ch {
'\\' => pattern.push_str("\\\\"),
'"' => pattern.push_str("\\\""),
'\0'..='\u{1f}' | '\u{7f}' => {
use std::fmt::Write as _;
write!(&mut pattern, "\\x{:02x}", ch as u32).unwrap();
}
_ => pattern.push(ch),
}
}
pattern.push('"');
let pattern = jj_file_pattern(&path);
self.jj(
root,
&["file", "show", "-r", &revset, "-T", "", "--", &pattern],
+37 -3
View File
@@ -39,6 +39,7 @@ pub struct Config {
pub file_explorer_view: String,
pub clipboard_mode: String,
pub git_blame: bool,
pub git_gutter: crate::gutter::GutterStyle,
pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine,
pub popup: PopupConfig,
@@ -83,6 +84,7 @@ impl Default for Config {
file_explorer_view: "tree".into(),
clipboard_mode: "system".into(),
git_blame: true,
git_gutter: crate::gutter::GutterStyle::default(),
top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(),
@@ -114,6 +116,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_explorer_view,
clipboard_mode,
git_blame,
git_gutter,
top_bar,
bottom_bar,
popup,
@@ -146,6 +149,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode;
config.git_blame = git_blame;
config.git_gutter = git_gutter;
config.top_bar = top_bar;
config.bottom_bar = bottom_bar;
config.popup = popup;
@@ -462,6 +466,7 @@ struct ConfigFrame {
file_explorer_view: String,
clipboard_mode: String,
git_blame: bool,
git_gutter: crate::gutter::GutterStyle,
#[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")]
@@ -1212,14 +1217,41 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
}
#[test]
fn git_gutter_config_modes_default_and_rejection() {
use crate::gutter::GutterStyle;
let fixture = Fixture::new();
let path = fixture.file("config.rb", "Vamp.configure { |_config| }");
assert_eq!(load_config(&path).unwrap().git_gutter, GutterStyle::Bar);
for (name, expected) in [
("bar", GutterStyle::Bar),
("symbols", GutterStyle::Symbols),
("line_numbers", GutterStyle::LineNumbers),
("none", GutterStyle::None),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.git_gutter = {name:?} }}"),
);
assert_eq!(load_config(&path).unwrap().git_gutter, expected);
}
for value in ["\"unsupported\"", "\"\"", "false", "7", ":bar"] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.git_gutter = {value} }}"),
);
assert!(load_config(&path).is_err(), "{value}");
}
}
#[test]
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
let fixture = Fixture::new();
@@ -1262,6 +1294,7 @@ end
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
"git_gutter": "bar",
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1928,6 +1961,7 @@ end"#,
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"git_blame": true,
"git_gutter": "bar",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
+10
View File
@@ -137,6 +137,7 @@ module Vamp
@file_explorer_view = "tree"
@clipboard_mode = "system"
@git_blame = true
@git_gutter = "bar"
@bar = BarConfig.new
@popup = PopupConfig.new
@labels = {}
@@ -197,6 +198,14 @@ module Vamp
@git_blame = enabled
end
# @param style [String] "bar", "symbols", "line_numbers", or "none"
def git_gutter=(style)
style = nonempty_string!(style, "git gutter")
raise ArgumentError, "git gutter must be bar, symbols, line_numbers, or none" unless %w[bar symbols line_numbers none].include?(style)
@git_gutter = style
end
# @param values [Hash{String, Symbol => String}]
# @return [Hash]
def labels=(values)
@@ -275,6 +284,7 @@ module Vamp
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"git_blame" => @git_blame,
"git_gutter" => @git_gutter,
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search },
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
+220
View File
@@ -165,6 +165,14 @@ struct AsyncRequests {
latest: Option<(u64, InvocationSource)>,
}
struct GutterSource {
buffer: usize,
revision: u64,
save_revision: u64,
path: PathBuf,
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 {
@@ -185,6 +193,11 @@ pub struct EditorSession {
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>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
@@ -240,6 +253,7 @@ impl EditorSession {
));
}
editor.set_git_blame(config.git_blame);
editor.set_gutter_style(config.git_gutter);
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) =
@@ -293,6 +307,11 @@ impl EditorSession {
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,
battery: None,
battery_enabled: false,
last_file_refresh: Instant::now(),
@@ -301,6 +320,7 @@ impl EditorSession {
};
session.synchronize_documents();
session.poll_battery();
session.poll_gutter_at(Instant::now());
Ok(session)
}
@@ -1035,6 +1055,151 @@ impl EditorSession {
Ok(changed)
}
fn poll_gutter_at(&mut self, now: Instant) -> bool {
if self.editor.should_quit()
|| self.editor.gutter_style() == crate::gutter::GutterStyle::None
{
self.gutter_worker = None;
self.gutter_sources.clear();
self.gutter_pending = false;
return false;
}
let sources_changed = !self
.editor
.documents()
.filter_map(|document| {
document
.path
.map(|path| (document.id, document.revision, document.save_revision, path))
})
.eq(self.gutter_sources.iter().map(|source| {
(
source.buffer,
source.revision,
source.save_revision,
source.path.as_path(),
)
}));
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 || refresh_due {
if sources_changed {
self.gutter_sources = self
.editor
.documents()
.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(),
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.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()
.map(|source| {
(
source.buffer,
source.path.clone(),
Arc::clone(&source.contents),
)
})
.collect();
match self
.gutter_worker
.as_mut()
.expect("gutter worker started")
.request(self.gutter_request_id, files)
{
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)) = self
.gutter_worker
.as_mut()
.and_then(crate::gutter::Worker::poll)
{
changed |= self.apply_gutter_result(id, result);
}
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
}
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
@@ -1168,6 +1333,7 @@ impl EditorSession {
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);
@@ -1233,6 +1399,60 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[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), None);
assert!(!session.apply_gutter_result(
second_request,
vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))],
));
session.poll_gutter_at(now);
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.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};
+197 -2
View File
@@ -849,6 +849,7 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
Event::Resize(_, _) => redraw = true,
Event::FocusGained => {
redraw |= session.refresh_blame();
redraw |= session.refresh_gutter();
query_theme = true;
}
Event::ColorSchemeChanged => query_theme = true,
@@ -1510,7 +1511,11 @@ fn draw_document(
_ => (workspace, None),
};
let digits = editor.lines().len().ilog10() as usize + 1;
let gutter_width = (digits + 1) as u16;
let marker_gutter = matches!(
editor.gutter_style(),
crate::gutter::GutterStyle::Bar | crate::gutter::GutterStyle::Symbols
);
let gutter_width = (digits + 1 + usize::from(marker_gutter)) as u16;
let [gutter, viewport] = Layout::horizontal([
Constraint::Length(if viewport.width > gutter_width {
gutter_width
@@ -1762,7 +1767,40 @@ fn draw_document(
} else {
row.abs_diff(cursor.row)
};
Line::styled(format!("{number:>digits$} "), style)
let change = editor.gutter_change(row);
let color = change.map(|change| match change {
crate::gutter::Change::Added => Color::Green,
crate::gutter::Change::Removed => Color::Red,
crate::gutter::Change::Modified => Color::Yellow,
});
let style = if editor.gutter_style() == crate::gutter::GutterStyle::LineNumbers
{
if row == cursor.row {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
color.map_or(style, |color| style.fg(color))
}
} else {
style
};
let mut spans = vec![Span::styled(format!("{number:>digits$}"), style)];
if marker_gutter {
let marker = match (editor.gutter_style(), change) {
(crate::gutter::GutterStyle::Bar, Some(_)) => "▌",
(_, Some(crate::gutter::Change::Added)) => "+",
(_, Some(crate::gutter::Change::Removed)) => "-",
(_, Some(crate::gutter::Change::Modified)) => "~",
(_, None) => " ",
};
spans.push(Span::styled(
marker,
Style::default().fg(color.unwrap_or(Color::DarkGray)),
));
}
spans.push(Span::raw(" "));
Line::from(spans)
})
.collect();
frame.render_widget(Paragraph::new(numbers), gutter);
@@ -7681,6 +7719,7 @@ end
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.txt", &"x".repeat(60))), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.execute("word_wrap.toggle");
let source = editor.blame_source().unwrap();
editor.set_line_blame(
@@ -9971,6 +10010,7 @@ end
CompletionEdit, CompletionItem, CompletionSnippet, SnippetTabstop, TextPosition,
};
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.handle(Input::Char('i'));
editor
.show_completion(vec![CompletionItem {
@@ -10207,6 +10247,7 @@ end
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.replace_diagnostics(
"fixture",
@@ -10299,6 +10340,7 @@ end
#[test]
fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"abc\n\nxy\nlast".into(),
@@ -10492,6 +10534,9 @@ end
Some(&config),
)
.unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
let source_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -10600,6 +10645,7 @@ end
let directory = Directory::new();
let path = directory.file("number.rs", "42");
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.set_clipboard_mode("separate").unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
@@ -10729,6 +10775,7 @@ end
#[test]
fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"ns Type local frozen field fixed param frozenparam".into(),
@@ -10785,6 +10832,7 @@ end
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.set_clipboard_mode("separate").unwrap();
editor
.replace_semantic_tokens(
@@ -10931,6 +10979,7 @@ end
vec![],
)
.unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.replace_semantic_tokens(
editor.buffer_id(),
@@ -11030,6 +11079,7 @@ end
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
terminal
@@ -11062,6 +11112,7 @@ end
fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() {
let directory = Directory::new();
let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
@@ -12764,6 +12815,7 @@ end
#[test]
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["word ".repeat(40); 20].join("\n"),
@@ -12864,6 +12916,7 @@ end
#[test]
fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["x".repeat(120); 30].join("\n"),
@@ -13225,6 +13278,9 @@ end
let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20));
let path = directory.file("wide.txt", &text);
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
session.handle(Input::Char('v'));
session.handle(Input::Char('l'));
let cursor = session.editor().cursor();
@@ -13557,6 +13613,9 @@ end
);
let other = directory.file("other.txt", "other");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
let buffer_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -13672,6 +13731,9 @@ end
let directory = Directory::new();
let path = directory.file("long.txt", "abcdefghijk");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
for _ in 0..8 {
session.handle(Input::Right);
}
@@ -13722,6 +13784,7 @@ end
let source = "abcdefghijklmnop\ntail";
let path = directory.file("wrapped.txt", source);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.execute("word_wrap.toggle");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -13818,6 +13881,7 @@ end
let source = format!("#a\t界e\u{301} z\n{}", vec!["# unseen"; 100].join("\n"));
let path = directory.file("wrapped.py", &source);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.replace_semantic_tokens(
editor.buffer_id(),
@@ -14017,6 +14081,7 @@ end
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap();
let mut viewports = BufferViewports::default();
@@ -14075,6 +14140,7 @@ end
let a = directory.file("a", "word\nword\nword");
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -14111,6 +14177,7 @@ end
#[test]
fn pending_motion_count_is_visible_and_escape_cancels_without_moving() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(["row"; 12].join("\n"))])
.unwrap();
@@ -14155,9 +14222,131 @@ end
);
}
#[test]
fn git_gutter_modes_colors_geometry_wrap_scroll_and_tiny_panes() {
use crate::gutter::{Change, GutterStyle};
let directory = Directory::new();
let path = directory.file("gutter.txt", "abcdefghijklmnop\nsecond\nthird\nfourth");
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
assert!(editor.set_gutter_changes(
editor.buffer_id(),
editor.text_revision(),
&path,
vec![
Some(Change::Added),
Some(Change::Removed),
Some(Change::Modified),
None
],
));
for (mode, marker) in [
(GutterStyle::Bar, "▌"),
(GutterStyle::Symbols, "+"),
(GutterStyle::LineNumbers, ""),
(GutterStyle::None, ""),
] {
editor.set_gutter_style(mode);
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.panels.caret.unwrap().0;
let markers = matches!(mode, GutterStyle::Bar | GutterStyle::Symbols);
assert_eq!(viewport.x, if markers { 3 } else { 2 });
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(viewport.x - 1, viewport.y)].symbol(), " ");
if markers {
assert_eq!(buffer[(1, viewport.y)].symbol(), marker);
for (offset, color) in [Color::Green, Color::Red, Color::Yellow]
.into_iter()
.enumerate()
{
assert_eq!(buffer[(1, viewport.y + offset as u16)].fg, color);
}
if mode == GutterStyle::Symbols {
assert_eq!(buffer[(1, viewport.y + 1)].symbol(), "-");
assert_eq!(buffer[(1, viewport.y + 2)].symbol(), "~");
}
} else if mode == GutterStyle::LineNumbers {
assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan);
assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Red);
assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Yellow);
}
assert_eq!(buffer[(viewport.x, viewport.y)].symbol(), "a");
}
editor.set_gutter_style(GutterStyle::Symbols);
editor.handle(Input::End);
let mut scrolled_views = BufferViewports::default();
let mut scrolled_terminal =
Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap();
scrolled_terminal
.draw(|frame| scrolled_views.draw(frame, &mut editor))
.unwrap();
assert!(scrolled_views.offsets[&editor.buffer_id()].1 > 0);
let text_area = scrolled_views.panels.caret.unwrap().0;
assert_eq!(
scrolled_terminal.backend().buffer()[(1, text_area.y)].symbol(),
"+"
);
assert_eq!(text_area.x, 3);
editor.handle(Input::Home);
editor.execute("word_wrap.toggle");
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 8)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.panels.caret.unwrap().0;
for (screen, &(_, continuation, _)) in views.document.visible.iter().enumerate() {
if continuation > 0 {
for x in 0..viewport.x {
assert_eq!(
terminal.backend().buffer()[(x, viewport.y + screen as u16)].symbol(),
" "
);
}
}
}
editor.execute("word_wrap.toggle");
editor.handle(Input::Down);
editor.handle(Input::Down);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 3)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.panels.caret.unwrap().0;
for (screen, &(row, _, _)) in views.document.visible.iter().enumerate() {
let expected = match row {
0 => "+",
1 => "-",
2 => "~",
_ => " ",
};
assert_eq!(
terminal.backend().buffer()[(1, viewport.y + screen as u16)].symbol(),
expected
);
}
for width in [1, 2, 3] {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 3)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.panels.caret.unwrap().0.x, 0);
}
editor.execute("pane.split_vertical");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains('~'));
}
#[test]
fn gutter_tracks_scrolled_rows_without_scrolling_with_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"0123456789界z\n".repeat(11),
@@ -14228,6 +14417,7 @@ end
#[test]
fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for count in [9usize, 10, 100] {
@@ -14727,6 +14917,7 @@ end
#[test]
fn selection_set_renders_all_ranges_and_counts_offscreen_targets() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"界\n界\n界\n界\n界".into(),
@@ -14769,6 +14960,7 @@ end
#[test]
fn secondary_carets_render_on_empty_lines_and_at_line_end() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace("a\n\n界".into())])
.unwrap();
@@ -14814,6 +15006,7 @@ end
#[test]
fn linewise_selection_highlights_copied_text_and_newlines_without_filling_rows() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"\t界e\u{301} \n\n".into(),
@@ -14897,6 +15090,7 @@ end
#[test]
fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}z\n\nxy".into(),
@@ -15003,6 +15197,7 @@ end
#[test]
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}zz".into(),