feat: add Git and Jujutsu gutter indicators
This commit is contained in:
+21
-8
@@ -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")?;
|
||||
}
|
||||
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
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user