feat: show diff hunk scope labels
Show per-side Tree-sitter scope paths inline and as sticky continuation labels. Keep presentation rows out of source coordinates, selections, and edits. Verified: cargo test (949 passed), cargo build, and real PTY smoke covering split/unified, wrapping, both split axes, sticky scrolling, live rename and undo. Size exception: 549 implementation lines added plus deleted, excluding tests and docs. Keep syntax extraction and coordinate-safe rendering together as one working behavior.
This commit is contained in:
+463
@@ -1,3 +1,4 @@
|
||||
use std::cell::OnceCell;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -40,6 +41,25 @@ pub struct SyntaxSpan {
|
||||
pub kind: SyntaxKind,
|
||||
}
|
||||
|
||||
/// Innermost named declaration, with its full enclosing path and end-exclusive source rows.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct ScopeContext<'a> {
|
||||
pub start_row: usize,
|
||||
pub end_row: usize,
|
||||
pub label: &'a str,
|
||||
}
|
||||
|
||||
struct CachedScope {
|
||||
start_row: usize,
|
||||
end_row: usize,
|
||||
label: String,
|
||||
}
|
||||
|
||||
struct ScopeIndex {
|
||||
declarations: Vec<CachedScope>,
|
||||
rows: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
/// Frontend-owned, per-buffer parser state. Revision and path identify unchanged input.
|
||||
#[derive(Default)]
|
||||
pub struct SyntaxCache {
|
||||
@@ -58,6 +78,7 @@ pub struct SyntaxCache {
|
||||
tree: Option<Tree>,
|
||||
injection_parser: Parser,
|
||||
injections: Vec<Layer>,
|
||||
scopes: OnceCell<ScopeIndex>,
|
||||
cursor: QueryCursor,
|
||||
captures: Vec<Capture>,
|
||||
highlights: Vec<Highlight>,
|
||||
@@ -131,6 +152,7 @@ impl SyntaxCache {
|
||||
visible.unwrap_or(std::slice::from_ref(&all_rows)),
|
||||
final_newline && !lines.is_empty(),
|
||||
);
|
||||
let _ = self.scope_at(0);
|
||||
}
|
||||
|
||||
fn update_rows(
|
||||
@@ -196,6 +218,7 @@ impl SyntaxCache {
|
||||
}
|
||||
self.highlighted.fill(false);
|
||||
self.context = None;
|
||||
self.scopes.take();
|
||||
}
|
||||
if language_changed {
|
||||
self.tree = None;
|
||||
@@ -251,6 +274,7 @@ impl SyntaxCache {
|
||||
self.tree = None;
|
||||
self.injections.clear();
|
||||
self.context = None;
|
||||
self.scopes.take();
|
||||
self.highlighted.fill(false);
|
||||
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
|
||||
spans.clear();
|
||||
@@ -268,6 +292,52 @@ impl SyntaxCache {
|
||||
self.lines.get(row).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Lazily index existing trees; subsequent lookups neither allocate nor reparse.
|
||||
pub(crate) fn scope_at(&self, row: usize) -> Option<ScopeContext<'_>> {
|
||||
let syntax = self.syntax?;
|
||||
let tree = self.tree.as_ref()?;
|
||||
if row >= self.lines.len()
|
||||
|| (!has_scope_language(syntax.id)
|
||||
&& !self
|
||||
.injections
|
||||
.iter()
|
||||
.any(|layer| has_scope_language(layer.syntax.id)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let index = self.scopes.get_or_init(|| {
|
||||
let mut index = ScopeIndex {
|
||||
declarations: Vec::new(),
|
||||
rows: vec![None; self.lines.len()],
|
||||
};
|
||||
collect_scopes(
|
||||
tree.root_node(),
|
||||
syntax.id,
|
||||
&self.source,
|
||||
&[],
|
||||
&mut index.declarations,
|
||||
&mut index.rows,
|
||||
);
|
||||
for layer in &self.injections {
|
||||
collect_scopes(
|
||||
layer.tree.root_node(),
|
||||
layer.syntax.id,
|
||||
&self.source,
|
||||
&layer.ranges,
|
||||
&mut index.declarations,
|
||||
&mut index.rows,
|
||||
);
|
||||
}
|
||||
index
|
||||
});
|
||||
let scope = &index.declarations[index.rows[row]?];
|
||||
Some(ScopeContext {
|
||||
start_row: scope.start_row,
|
||||
end_row: scope.end_row,
|
||||
label: &scope.label,
|
||||
})
|
||||
}
|
||||
|
||||
/// String coverage before foreground overrides, including embedded-language highlighting.
|
||||
/// An included line break has end_byte == line.len() + 1; foreground spans exclude it.
|
||||
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
|
||||
@@ -473,6 +543,244 @@ impl SyntaxCache {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_scope_language(language: &str) -> bool {
|
||||
matches!(
|
||||
language,
|
||||
"rust"
|
||||
| "python"
|
||||
| "ruby"
|
||||
| "javascript"
|
||||
| "typescript"
|
||||
| "typescriptreact"
|
||||
| "go"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "shellscript"
|
||||
| "zig"
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_scopes(
|
||||
root: Node<'_>,
|
||||
language: &str,
|
||||
source: &str,
|
||||
ranges: &[Range],
|
||||
scopes: &mut Vec<CachedScope>,
|
||||
rows: &mut [Option<usize>],
|
||||
) {
|
||||
if !has_scope_language(language) {
|
||||
return;
|
||||
}
|
||||
let mut cursor = root.walk();
|
||||
let mut ancestors: Vec<(usize, usize)> = Vec::new();
|
||||
loop {
|
||||
let node = cursor.node();
|
||||
while ancestors
|
||||
.last()
|
||||
.is_some_and(|(_, end)| *end <= node.start_byte())
|
||||
{
|
||||
ancestors.pop();
|
||||
}
|
||||
if let Some((extent, name)) =
|
||||
scope_declaration(node, language, source).filter(|(extent, _)| {
|
||||
ranges.is_empty()
|
||||
|| ranges.iter().any(|range| {
|
||||
range.start_byte <= extent.start_byte()
|
||||
&& extent.end_byte() <= range.end_byte
|
||||
})
|
||||
})
|
||||
{
|
||||
let parent = ancestors
|
||||
.last()
|
||||
.map_or("", |(index, _)| scopes[*index].label.as_str());
|
||||
let label = if parent.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{parent} › {name}")
|
||||
};
|
||||
let start_row = extent.start_position().row;
|
||||
let end = extent.end_position();
|
||||
let end_row = end.row + usize::from(end.column > 0);
|
||||
let index = scopes.len();
|
||||
scopes.push(CachedScope {
|
||||
start_row,
|
||||
end_row,
|
||||
label,
|
||||
});
|
||||
let end_row = end_row.min(rows.len());
|
||||
rows[start_row.min(end_row)..end_row].fill(Some(index));
|
||||
ancestors.push((index, node.end_byte()));
|
||||
}
|
||||
if cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
while !cursor.goto_next_sibling() {
|
||||
if !cursor.goto_parent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scope_text<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
|
||||
(!node.has_error() && !node.is_missing() && node.kind() != "computed_property_name")
|
||||
.then(|| source.get(node.byte_range()))
|
||||
.flatten()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
}
|
||||
|
||||
fn scope_declaration<'a>(
|
||||
node: Node<'a>,
|
||||
language: &str,
|
||||
source: &str,
|
||||
) -> Option<(Node<'a>, String)> {
|
||||
if !node.is_named() || node.is_missing() {
|
||||
return None;
|
||||
}
|
||||
let field = |name| node.child_by_field_name(name);
|
||||
let mut extent = node;
|
||||
let name = match (language, node.kind()) {
|
||||
("rust", "impl_item") => {
|
||||
let target = scope_text(field("type")?, source)?;
|
||||
let label = match field("trait").and_then(|node| scope_text(node, source)) {
|
||||
Some(trait_name) => scope_label(&["impl", trait_name, "for", target]),
|
||||
None => scope_label(&["impl", target]),
|
||||
};
|
||||
return Some((node, label));
|
||||
}
|
||||
(
|
||||
"rust",
|
||||
"function_item"
|
||||
| "function_signature_item"
|
||||
| "trait_item"
|
||||
| "mod_item"
|
||||
| "struct_item"
|
||||
| "enum_item",
|
||||
)
|
||||
| ("python", "function_definition" | "class_definition")
|
||||
| ("ruby", "class" | "module" | "method" | "singleton_method")
|
||||
| (
|
||||
"javascript" | "typescript" | "typescriptreact",
|
||||
"function_declaration"
|
||||
| "generator_function_declaration"
|
||||
| "method_definition"
|
||||
| "class_declaration"
|
||||
| "class"
|
||||
| "abstract_class_declaration"
|
||||
| "interface_declaration"
|
||||
| "internal_module",
|
||||
)
|
||||
| ("go", "function_declaration")
|
||||
| (
|
||||
"c" | "cpp",
|
||||
"class_specifier"
|
||||
| "struct_specifier"
|
||||
| "union_specifier"
|
||||
| "enum_specifier"
|
||||
| "namespace_definition",
|
||||
)
|
||||
| ("shellscript", "function_definition")
|
||||
| ("zig", "function_declaration") => field("name")?,
|
||||
("go", "method_declaration") => {
|
||||
let receiver = field("receiver")?
|
||||
.named_child(0)?
|
||||
.child_by_field_name("type")?;
|
||||
let receiver = scope_text(receiver, source)?;
|
||||
let name = scope_text(field("name")?, source)?;
|
||||
return Some((node, scope_label(&[receiver, "›", name])));
|
||||
}
|
||||
("go", "type_spec")
|
||||
if field("type")
|
||||
.is_some_and(|node| matches!(node.kind(), "struct_type" | "interface_type")) =>
|
||||
{
|
||||
field("name")?
|
||||
}
|
||||
("c" | "cpp", "function_definition") => {
|
||||
let mut declarator = field("declarator")?;
|
||||
loop {
|
||||
if matches!(
|
||||
declarator.kind(),
|
||||
"identifier"
|
||||
| "field_identifier"
|
||||
| "qualified_identifier"
|
||||
| "operator_name"
|
||||
| "destructor_name"
|
||||
) {
|
||||
break declarator;
|
||||
}
|
||||
declarator = declarator.child_by_field_name("declarator").or_else(|| {
|
||||
matches!(
|
||||
declarator.kind(),
|
||||
"reference_declarator" | "parenthesized_declarator"
|
||||
)
|
||||
.then(|| declarator.named_child(0))
|
||||
.flatten()
|
||||
})?;
|
||||
}
|
||||
}
|
||||
(
|
||||
"javascript" | "typescript" | "typescriptreact",
|
||||
"arrow_function" | "function_expression" | "generator_function",
|
||||
) => {
|
||||
if let Some(name) = field("name") {
|
||||
name
|
||||
} else {
|
||||
extent = node.parent()?;
|
||||
match extent.kind() {
|
||||
"variable_declarator" => {
|
||||
let name = extent.child_by_field_name("name")?;
|
||||
(name.kind() == "identifier").then_some(name)?
|
||||
}
|
||||
"pair" => extent.child_by_field_name("key")?,
|
||||
"field_definition" => extent.child_by_field_name("property")?,
|
||||
"public_field_definition" => extent.child_by_field_name("name")?,
|
||||
"assignment_expression" => {
|
||||
let name = extent.child_by_field_name("left")?;
|
||||
matches!(name.kind(), "identifier" | "member_expression").then_some(name)?
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
"zig",
|
||||
"struct_declaration" | "union_declaration" | "enum_declaration" | "opaque_declaration",
|
||||
) => {
|
||||
extent = node.parent()?;
|
||||
if extent.kind() != "variable_declaration"
|
||||
|| node.prev_sibling().is_none_or(|node| node.kind() != "=")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut cursor = extent.walk();
|
||||
extent
|
||||
.named_children(&mut cursor)
|
||||
.find(|node| node.kind() == "identifier")?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if let Some(parent) = node.parent()
|
||||
&& parent.kind() == "decorated_definition"
|
||||
{
|
||||
extent = parent;
|
||||
}
|
||||
Some((extent, scope_label(&[scope_text(name, source)?])))
|
||||
}
|
||||
|
||||
fn scope_label(parts: &[&str]) -> String {
|
||||
let mut label = String::new();
|
||||
for word in parts.iter().flat_map(|part| part.split_whitespace()) {
|
||||
if !label.is_empty() {
|
||||
label.push(' ');
|
||||
}
|
||||
label.extend(
|
||||
word.chars()
|
||||
.map(|ch| if ch.is_control() { '�' } else { ch }),
|
||||
);
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
fn same_language(left: Option<&LanguageSpec>, right: Option<&LanguageSpec>) -> bool {
|
||||
match (left, right) {
|
||||
(Some(left), Some(right)) => std::ptr::eq(left, right),
|
||||
@@ -1249,6 +1557,161 @@ mod tests {
|
||||
source.split('\n').map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_follow_real_declarations_and_each_sources_own_tree() {
|
||||
let original = "impl State {\n fn old() {\n call();\n }\n}\n\nfn next() {\n call();\n}\n";
|
||||
let before = cache(original, "old.rs");
|
||||
let mut live = cache(&original.replace("old()", "renamed()"), "new.rs");
|
||||
assert_eq!(
|
||||
before.scope_at(2),
|
||||
Some(ScopeContext {
|
||||
start_row: 1,
|
||||
end_row: 4,
|
||||
label: "impl State › old",
|
||||
})
|
||||
);
|
||||
assert_eq!(before.scope_at(3).unwrap().label, "impl State › old");
|
||||
assert_eq!(before.scope_at(4).unwrap().label, "impl State");
|
||||
assert_eq!(before.scope_at(5), None);
|
||||
assert_eq!(
|
||||
before.scope_at(6),
|
||||
Some(ScopeContext {
|
||||
start_row: 6,
|
||||
end_row: 9,
|
||||
label: "next",
|
||||
})
|
||||
);
|
||||
assert_eq!(before.scope_at(9), None);
|
||||
assert_eq!(before.scope_at(usize::MAX), None);
|
||||
let changed = lines(&original.replace("old()", "updated()"));
|
||||
live.update_visible(&changed, Some(Path::new("new.rs")), 1, &[2..3]);
|
||||
assert_eq!(live.scope_at(2).unwrap().label, "impl State › updated");
|
||||
assert_eq!(before.scope_at(2).unwrap().label, "impl State › old");
|
||||
let counts = live.parse_counts();
|
||||
live.update_visible(&changed, Some(Path::new("new.rs")), 2, &[6..9]);
|
||||
assert_eq!(live.parse_counts(), counts);
|
||||
live.update_visible(&changed, Some(Path::new("renamed.txt")), 2, &[0..1]);
|
||||
assert_eq!(live.scope_at(2), None);
|
||||
assert_eq!(before.scope_at(2).unwrap().label, "impl State › old");
|
||||
let incomplete = cache("impl State {\n fn run() {\n let = ;\n }\n}", "a.rs");
|
||||
assert_eq!(incomplete.scope_at(2).unwrap().label, "impl State › run");
|
||||
|
||||
let python = cache(
|
||||
"class State:\n @decorator\n def run(self):\n work()\n\n def next(self):\n work()\n",
|
||||
"a.py",
|
||||
);
|
||||
assert_eq!(
|
||||
python.scope_at(1),
|
||||
Some(ScopeContext {
|
||||
start_row: 1,
|
||||
end_row: 4,
|
||||
label: "State › run",
|
||||
})
|
||||
);
|
||||
assert_eq!(python.scope_at(4).unwrap().label, "State");
|
||||
assert_eq!(python.scope_at(5).unwrap().label, "State › next");
|
||||
assert_eq!(python.scope_at(7), None);
|
||||
|
||||
for (path, source, expected) in [
|
||||
(
|
||||
"a.rs",
|
||||
"trait State {\n fn run(&self) {\n work();\n }\n}",
|
||||
Some("State › run"),
|
||||
),
|
||||
(
|
||||
"a.rb",
|
||||
"class State\n def run\n work\n end\nend",
|
||||
Some("State › run"),
|
||||
),
|
||||
(
|
||||
"a.js",
|
||||
"function outer() {\n function inner() {\n work();\n }\n}",
|
||||
Some("outer › inner"),
|
||||
),
|
||||
(
|
||||
"a.ts",
|
||||
"class State {\n run = () => {\n work();\n };\n}",
|
||||
Some("State › run"),
|
||||
),
|
||||
(
|
||||
"a.tsx",
|
||||
"const View = () => {\n work();\n return <main />;\n};",
|
||||
Some("View"),
|
||||
),
|
||||
(
|
||||
"a.js",
|
||||
"const handlers = { run: () => {\n work();\n} };",
|
||||
Some("run"),
|
||||
),
|
||||
(
|
||||
"a.js",
|
||||
"State.run = () => {\n work();\n};",
|
||||
Some("State.run"),
|
||||
),
|
||||
(
|
||||
"a.go",
|
||||
"package main\nfunc (s *State) run() {\n work()\n}",
|
||||
Some("*State › run"),
|
||||
),
|
||||
(
|
||||
"a.go",
|
||||
"package main\nfunc run() {\n work()\n}",
|
||||
Some("run"),
|
||||
),
|
||||
(
|
||||
"a.c",
|
||||
"int run(void) {\n work();\n return 7;\n}",
|
||||
Some("run"),
|
||||
),
|
||||
(
|
||||
"a.cpp",
|
||||
"class State {\n void run() {\n work();\n }\n};",
|
||||
Some("State › run"),
|
||||
),
|
||||
(
|
||||
"a.cpp",
|
||||
"void State::run() {\n work();\n}",
|
||||
Some("State::run"),
|
||||
),
|
||||
("a.sh", "run() {\n work\n}", Some("run")),
|
||||
(
|
||||
"a.zig",
|
||||
"const State = struct {\n pub fn run() void {\n work();\n }\n};",
|
||||
Some("State › run"),
|
||||
),
|
||||
("a.md", "```rust\nfn run() {\n work();\n}\n```", Some("run")),
|
||||
(
|
||||
"a.html",
|
||||
"<script>\nfunction run() {\n work();\n}\n</script>",
|
||||
Some("run"),
|
||||
),
|
||||
("a.js", "use(() => {\n work();\n});", None),
|
||||
(
|
||||
"a.js",
|
||||
"const handlers = { [key]: () => {\n work();\n} };",
|
||||
None,
|
||||
),
|
||||
("a.txt", "fn run() {\n work();\n}", None),
|
||||
("a.unknown", "fn run() {\n work();\n}", None),
|
||||
("a.json", "{\"work\": \"function run() {}\"}", None),
|
||||
("a.yaml", "work: function run() {}", None),
|
||||
] {
|
||||
let syntax = cache(source, path);
|
||||
let row = source[..source.find("work").unwrap()]
|
||||
.bytes()
|
||||
.filter(|byte| *byte == b'\n')
|
||||
.count();
|
||||
assert_eq!(
|
||||
syntax.scope_at(row).map(|scope| scope.label),
|
||||
expected,
|
||||
"{path}: {source}"
|
||||
);
|
||||
if let Some(scope) = syntax.scope_at(row) {
|
||||
assert!(scope.start_row <= row && row < scope.end_row, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source_range(source: &str) -> Range {
|
||||
Range {
|
||||
start_byte: 0,
|
||||
|
||||
+486
-65
@@ -512,6 +512,8 @@ impl HistoryDiff {
|
||||
position: self.top,
|
||||
segments,
|
||||
final_segments,
|
||||
scope: [false; 2],
|
||||
unwrapped: false,
|
||||
});
|
||||
}
|
||||
let previous = self.top;
|
||||
@@ -3635,6 +3637,27 @@ fn draw_diff(
|
||||
PaneLayout::After => [Rect::default(), panes],
|
||||
}
|
||||
};
|
||||
// Parse complete trees before projecting context; color only visible source rows below.
|
||||
if let Some(id) = diff.comparison_id
|
||||
&& let Some(file) = selected.filter(|file| file.loaded)
|
||||
{
|
||||
panels
|
||||
.diff_syntax
|
||||
.get_or_insert_with(DiffSyntaxCache::default)
|
||||
.update(
|
||||
id,
|
||||
diff.file,
|
||||
file,
|
||||
diff.after_source_lines(file),
|
||||
diff.live_revision,
|
||||
Some(DiffSyntaxViewport {
|
||||
diff,
|
||||
top: diff.top,
|
||||
areas: [Rect::default(); 2],
|
||||
wrapped: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let mut document_caret = None;
|
||||
let mut top = diff.top;
|
||||
let mut before_left = diff.left_before;
|
||||
@@ -3652,26 +3675,8 @@ fn draw_diff(
|
||||
})
|
||||
})
|
||||
.and_then(|previous| {
|
||||
let editor = editor?;
|
||||
let focus = editor.diff_buffer_focus()?;
|
||||
if previous.view_mode != diff.view_mode || previous.split_axis != diff.split_axis {
|
||||
return previous.caret_row;
|
||||
}
|
||||
let cursor = editor.cursor();
|
||||
if previous.wrapping {
|
||||
let file = selected?;
|
||||
let viewport = panels.diff_wrap.as_mut()?;
|
||||
let position = viewport.caret_position(diff, file, focus, cursor)?;
|
||||
viewport
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.position == position)
|
||||
} else {
|
||||
let display = diff.source_display_row(focus, cursor.row)?;
|
||||
let row = display.checked_sub(previous.viewport?.2)?;
|
||||
let side = usize::from(focus == Focus::After) + 1;
|
||||
(row < usize::from(diff_inner(previous.regions[side].0).height)).then_some(row)
|
||||
}
|
||||
editor?.diff_buffer_focus()?;
|
||||
previous.caret_row
|
||||
});
|
||||
let center_row = editor.and_then(|editor| {
|
||||
let focus = editor.diff_buffer_focus()?;
|
||||
@@ -3693,7 +3698,9 @@ fn draw_diff(
|
||||
.then_some(height / 2)
|
||||
});
|
||||
let toggle_row = center_row.or(toggle_row);
|
||||
if wrapping && let Some(file) = selected.filter(|file| file.loaded) {
|
||||
if let Some(file) = selected.filter(|file| file.loaded)
|
||||
&& let Some(syntax) = &panels.diff_syntax
|
||||
{
|
||||
let caret = editor
|
||||
.filter(|editor| editor.diff_interacting() && diff.follow_caret)
|
||||
.and_then(|editor| {
|
||||
@@ -3711,6 +3718,7 @@ fn draw_diff(
|
||||
caret,
|
||||
toggle_row,
|
||||
editor.map_or(Some(0), Editor::scroll_distance),
|
||||
syntax,
|
||||
);
|
||||
top = viewport.top.display;
|
||||
} else {
|
||||
@@ -3725,15 +3733,6 @@ fn draw_diff(
|
||||
Focus::After => (after_area, &mut after_left),
|
||||
Focus::Files => unreachable!("native diff buffers belong to a text pane"),
|
||||
};
|
||||
let height = usize::from(diff_inner(pane).height);
|
||||
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
|
||||
if let Some(anchor) = toggle_row {
|
||||
top = row.saturating_sub(anchor.min(height.saturating_sub(1)));
|
||||
}
|
||||
if toggle_row.is_none() {
|
||||
top = scroll_offset(top, row, height, editor.scroll_distance());
|
||||
}
|
||||
}
|
||||
let source_len = selected.map_or(0, |file| match focus {
|
||||
Focus::Before => file.before.len(),
|
||||
Focus::After => file.after.len(),
|
||||
@@ -4063,10 +4062,65 @@ fn draw_diff_pane(
|
||||
let (top, left, previous_width) = viewport;
|
||||
let gutter = diff_gutter(diff, file, usize::from(content.width), focus);
|
||||
let width = usize::from(content.width).saturating_sub(gutter);
|
||||
if wrapped.is_none() && previous_width.is_some_and(|previous| usize::from(previous) != width) {
|
||||
if !diff.word_wrap
|
||||
&& !content_only
|
||||
&& previous_width.is_some_and(|previous| usize::from(previous) != width)
|
||||
{
|
||||
*left = diff.bound_horizontal(focus, *left, width);
|
||||
}
|
||||
let left = *left;
|
||||
if let Some(rows) = wrapped {
|
||||
for (screen, row) in rows.iter().take(usize::from(content.height)).enumerate() {
|
||||
if row.scope == [false; 2] {
|
||||
continue;
|
||||
}
|
||||
let side = if unified {
|
||||
usize::from(!row.scope[0])
|
||||
} else {
|
||||
usize::from(!before)
|
||||
};
|
||||
if !row.scope[side] {
|
||||
continue;
|
||||
}
|
||||
let source_focus = if side == 0 {
|
||||
Focus::Before
|
||||
} else {
|
||||
Focus::After
|
||||
};
|
||||
let Some(scope) = diff
|
||||
.display_source_row(source_focus, row.position.display)
|
||||
.and_then(|source| syntax[side].scope_at(source))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let prefix = if width < 4 {
|
||||
""
|
||||
} else if !unified {
|
||||
"› "
|
||||
} else if row.scope == [true; 2] {
|
||||
"± "
|
||||
} else if side == 0 {
|
||||
"− "
|
||||
} else {
|
||||
"+ "
|
||||
};
|
||||
let prefix_width = UnicodeWidthStr::width(prefix);
|
||||
let available = width.saturating_sub(prefix_width);
|
||||
let label = if UnicodeWidthStr::width(scope.label) > available {
|
||||
scope.label.rsplit(" › ").next().unwrap_or(scope.label)
|
||||
} else {
|
||||
scope.label
|
||||
};
|
||||
let style = Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::ITALIC);
|
||||
let x = content.x + gutter as u16;
|
||||
let y = content.y + screen as u16;
|
||||
let buffer = frame.buffer_mut();
|
||||
buffer.set_stringn(x, y, prefix, width, style);
|
||||
buffer.set_stringn(x + prefix_width as u16, y, label, available, style);
|
||||
}
|
||||
}
|
||||
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
|
||||
let row = &diff.display_rows[display_row];
|
||||
let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1);
|
||||
@@ -4324,6 +4378,8 @@ struct WrappedDiffRow {
|
||||
position: DiffWrapPosition,
|
||||
segments: [Option<crate::wrap::Segment>; 2],
|
||||
final_segments: [bool; 2],
|
||||
scope: [bool; 2],
|
||||
unwrapped: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -4390,6 +4446,15 @@ impl DiffWrapViewport {
|
||||
) -> WrappedDiffRow {
|
||||
let mut segments = [None; 2];
|
||||
let mut final_segments = [false; 2];
|
||||
if !diff.word_wrap {
|
||||
return WrappedDiffRow {
|
||||
position,
|
||||
segments,
|
||||
final_segments,
|
||||
scope: [false; 2],
|
||||
unwrapped: true,
|
||||
};
|
||||
}
|
||||
if matches!(
|
||||
diff.display_rows.get(position.display),
|
||||
Some(DisplayRow::Line(_))
|
||||
@@ -4413,10 +4478,15 @@ impl DiffWrapViewport {
|
||||
position,
|
||||
segments,
|
||||
final_segments,
|
||||
scope: [false; 2],
|
||||
unwrapped: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize {
|
||||
if !diff.word_wrap {
|
||||
return 1;
|
||||
}
|
||||
if !matches!(diff.display_rows.get(display), Some(DisplayRow::Line(_))) {
|
||||
return 1;
|
||||
}
|
||||
@@ -4462,13 +4532,30 @@ impl DiffWrapViewport {
|
||||
position
|
||||
}
|
||||
|
||||
fn fill(&mut self, diff: &DiffState, file: &ChangedFile, height: usize) {
|
||||
fn fill(
|
||||
&mut self,
|
||||
diff: &DiffState,
|
||||
file: &ChangedFile,
|
||||
height: usize,
|
||||
syntax: &DiffSyntaxCache,
|
||||
) {
|
||||
self.rows.clear();
|
||||
if diff.display_rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut position = self.top;
|
||||
for _ in 0..height {
|
||||
while self.rows.len() < height {
|
||||
for scope in syntax.labels(diff, position, self.rows.is_empty()) {
|
||||
if scope.iter().any(|side| *side) && self.rows.len() + 1 < height {
|
||||
self.rows.push(WrappedDiffRow {
|
||||
position,
|
||||
segments: [None; 2],
|
||||
final_segments: [false; 2],
|
||||
scope,
|
||||
unwrapped: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
let row = self.row(diff, file, position);
|
||||
self.rows.push(row);
|
||||
let next = self.step(diff, file, position, true);
|
||||
@@ -4494,7 +4581,11 @@ impl DiffWrapViewport {
|
||||
};
|
||||
let text = source.get(cursor.row).map_or("", String::as_str);
|
||||
let column = display_column(text, cursor.column);
|
||||
let continuation = caret_segment(cache.segments(cursor.row, text), column);
|
||||
let continuation = if diff.word_wrap {
|
||||
caret_segment(cache.segments(cursor.row, text), column)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some(DiffWrapPosition {
|
||||
display,
|
||||
continuation,
|
||||
@@ -4509,6 +4600,7 @@ impl DiffWrapViewport {
|
||||
caret: Option<(Focus, Cursor)>,
|
||||
anchor_row: Option<usize>,
|
||||
distance: Option<usize>,
|
||||
syntax: &DiffSyntaxCache,
|
||||
) {
|
||||
let Some(id) = diff.comparison_id else { return };
|
||||
let key = Some((id, diff.file, diff.live_revision));
|
||||
@@ -4548,7 +4640,7 @@ impl DiffWrapViewport {
|
||||
let height = usize::from(diff_inner(areas[active]).height);
|
||||
let paint_height =
|
||||
usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
|
||||
self.fill(diff, file, paint_height);
|
||||
self.fill(diff, file, paint_height, syntax);
|
||||
if let Some((focus, cursor)) = caret
|
||||
&& let Some(position) = self.caret_position(diff, file, focus, cursor)
|
||||
{
|
||||
@@ -4557,7 +4649,7 @@ impl DiffWrapViewport {
|
||||
.rows
|
||||
.iter()
|
||||
.take(height)
|
||||
.position(|row| row.position == position);
|
||||
.position(|row| row.scope == [false; 2] && row.position == position);
|
||||
let anchor = anchor_row.or_else(|| {
|
||||
if height == 0 {
|
||||
None
|
||||
@@ -4574,10 +4666,28 @@ impl DiffWrapViewport {
|
||||
});
|
||||
if let Some(row) = anchor {
|
||||
self.top = position;
|
||||
for _ in 0..row.min(height.saturating_sub(1)) {
|
||||
self.top = self.step(diff, file, self.top, false);
|
||||
let target = row.min(height.saturating_sub(1));
|
||||
let label_count = |position, sticky| {
|
||||
syntax
|
||||
.labels(diff, position, sticky)
|
||||
.iter()
|
||||
.filter(|scope| scope.iter().any(|side| *side))
|
||||
.count()
|
||||
.min(height.saturating_sub(1))
|
||||
};
|
||||
let mut screen = label_count(self.top, true);
|
||||
while screen < target {
|
||||
let previous = self.step(diff, file, self.top, false);
|
||||
let next_screen =
|
||||
screen + 1 + label_count(self.top, false) + label_count(previous, true)
|
||||
- label_count(self.top, true);
|
||||
if previous == self.top || next_screen > target {
|
||||
break;
|
||||
}
|
||||
self.top = previous;
|
||||
screen = next_screen;
|
||||
}
|
||||
self.fill(diff, file, paint_height);
|
||||
self.fill(diff, file, paint_height, syntax);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4592,7 +4702,14 @@ fn diff_screen_rows<'a>(
|
||||
(0..usize::from(height)).filter_map(move |screen| {
|
||||
if let Some(rows) = wrapped {
|
||||
rows.get(screen)
|
||||
.map(|row| (screen, row.position.display, Some(row.segments)))
|
||||
.filter(|row| row.scope == [false; 2])
|
||||
.map(|row| {
|
||||
(
|
||||
screen,
|
||||
row.position.display,
|
||||
(!row.unwrapped).then_some(row.segments),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
(top + screen < diff.display_rows.len()).then_some((screen, top + screen, None))
|
||||
}
|
||||
@@ -4618,6 +4735,39 @@ struct DiffSyntaxCache {
|
||||
}
|
||||
|
||||
impl DiffSyntaxCache {
|
||||
fn scopes<'a>(
|
||||
&'a self,
|
||||
diff: &DiffState,
|
||||
display: usize,
|
||||
) -> [Option<crate::syntax::ScopeContext<'a>>; 2] {
|
||||
[Focus::Before, Focus::After].map(|focus| {
|
||||
let source = diff.display_source_row(focus, display)?;
|
||||
let syntax = if focus == Focus::Before {
|
||||
&self.before
|
||||
} else {
|
||||
&self.after
|
||||
};
|
||||
syntax.scope_at(source)
|
||||
})
|
||||
}
|
||||
|
||||
fn labels(&self, diff: &DiffState, position: DiffWrapPosition, sticky: bool) -> [[bool; 2]; 2] {
|
||||
let scopes = self.scopes(diff, position.display);
|
||||
if !sticky
|
||||
&& (position.continuation > 0
|
||||
|| position.display > 0 && scopes == self.scopes(diff, position.display - 1))
|
||||
{
|
||||
return [[false; 2]; 2];
|
||||
}
|
||||
if diff.view_mode == ViewMode::Unified
|
||||
&& scopes[0].map(|scope| scope.label) != scopes[1].map(|scope| scope.label)
|
||||
{
|
||||
[[scopes[0].is_some(), false], [false, scopes[1].is_some()]]
|
||||
} else {
|
||||
[[scopes[0].is_some(), scopes[1].is_some()], [false; 2]]
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
comparison_id: u64,
|
||||
@@ -4692,12 +4842,15 @@ fn diff_visible_rows(
|
||||
} else {
|
||||
pane.height
|
||||
};
|
||||
let count = wrapped.map_or(usize::from(height), |rows| {
|
||||
rows.last().map_or(0, |row| row.position.display - top + 1)
|
||||
});
|
||||
for display in top..top.saturating_add(count).min(diff.display_rows.len()) {
|
||||
for (_, display, _) in diff_screen_rows(diff, top, height, wrapped) {
|
||||
let source =
|
||||
diff.display_source_row(if before { Focus::Before } else { Focus::After }, display);
|
||||
if ranges
|
||||
.last()
|
||||
.is_some_and(|range| source.is_some_and(|source| range.contains(&source)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(source) = source {
|
||||
if let Some(last) = ranges.last_mut()
|
||||
&& last.end == source
|
||||
@@ -7741,6 +7894,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_scope_labels_identify_multiple_scopes_and_sticky_renamed_sides() {
|
||||
let before = "impl Review {\n fn old_name(&self) {\n let first = 1;\n let second = 2;\n }\n fn next(&self) {\n let third = 3;\n }\n}";
|
||||
let after = before
|
||||
.replace("old_name", "new_name")
|
||||
.replace("first = 1", "first = 9");
|
||||
for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] {
|
||||
for wrapping in [false, true] {
|
||||
let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]);
|
||||
diff.files_visible = false;
|
||||
diff.split_axis = axis;
|
||||
diff.word_wrap = wrapping;
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(160, 48)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let rows = &panels.diff_wrap.as_ref().unwrap().rows;
|
||||
for (side, name, other) in
|
||||
[(0, "old_name", "new_name"), (1, "new_name", "old_name")]
|
||||
{
|
||||
let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0);
|
||||
let labels = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.scope[side])
|
||||
.map(|(screen, _)| {
|
||||
(area.x..area.right())
|
||||
.map(|x| {
|
||||
terminal.backend().buffer()[(x, area.y + screen as u16)]
|
||||
.symbol()
|
||||
})
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(labels.iter().any(|label| label.contains(name)));
|
||||
assert!(labels.iter().any(|label| label.contains("next")));
|
||||
assert!(!labels.iter().any(|label| label.contains(other)));
|
||||
}
|
||||
let syntax = panels.diff_syntax.as_ref().unwrap();
|
||||
let parses = (syntax.before.parse_counts(), syntax.after.parse_counts());
|
||||
diff.top = diff.source_display_row(Focus::Before, 2).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let rows = &panels.diff_wrap.as_ref().unwrap().rows;
|
||||
assert_eq!(rows[0].scope, [true; 2]);
|
||||
assert_eq!(rows[1].scope, [false; 2]);
|
||||
assert_eq!(rows[0].position, rows[1].position);
|
||||
let first = diff_screen_rows(&diff, diff.top, 48, Some(rows))
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!((first.0, first.1), (1, diff.top));
|
||||
let syntax = panels.diff_syntax.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(syntax.before.parse_counts(), syntax.after.parse_counts()),
|
||||
parses
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut narrow = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]);
|
||||
narrow.files_visible = false;
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 30)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &narrow, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("› old_name"));
|
||||
assert!(screen_text(&terminal).contains("› new_name"));
|
||||
let diff = diff_comparison(vec![syntax_diff_file("scope.txt", before, &after)]);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(
|
||||
panels
|
||||
.diff_wrap
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.scope == [false; 2])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_syntax_refreshes_same_path_working_snapshots_without_live_buffer_styles() {
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||||
@@ -7754,11 +7986,19 @@ mod tests {
|
||||
diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]);
|
||||
diff.files_visible = false;
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let source_row = panels
|
||||
.diff_wrap
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.scope == [false; 2])
|
||||
.unwrap() as u16;
|
||||
let after = panels.diff.as_ref().unwrap().regions[2].0;
|
||||
let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1)];
|
||||
let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1 + source_row)];
|
||||
assert_eq!(cell.fg, foreground, "{source}");
|
||||
let before = panels.diff.as_ref().unwrap().regions[1].0;
|
||||
let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1)];
|
||||
let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1 + source_row)];
|
||||
assert_eq!(cell.fg, Color::Magenta);
|
||||
}
|
||||
}
|
||||
@@ -7795,11 +8035,30 @@ mod tests {
|
||||
assert!(syntax.before.parse_counts().1 > 0);
|
||||
assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword);
|
||||
assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword);
|
||||
for region in [
|
||||
for (side, region) in [
|
||||
panels.diff.as_ref().unwrap().regions[1].0,
|
||||
panels.diff.as_ref().unwrap().regions[2].0,
|
||||
] {
|
||||
let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 2)];
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let focus = if side == 0 {
|
||||
Focus::Before
|
||||
} else {
|
||||
Focus::After
|
||||
};
|
||||
let screen = panels
|
||||
.diff_wrap
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| {
|
||||
row.scope == [false; 2]
|
||||
&& diff.display_source_row(focus, row.position.display) == Some(1)
|
||||
})
|
||||
.unwrap();
|
||||
let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 1 + screen as u16)];
|
||||
assert_eq!(cell.fg, Color::Magenta);
|
||||
}
|
||||
}
|
||||
@@ -8707,6 +8966,14 @@ mod tests {
|
||||
let geometry = panels.diff.as_ref().unwrap();
|
||||
let before = geometry.regions[1].0;
|
||||
let after = geometry.regions[2].0;
|
||||
let source_row = panels
|
||||
.diff_wrap
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.scope == [false; 2])
|
||||
.unwrap() as u16;
|
||||
let buffer = terminal.backend().buffer();
|
||||
for pane in [before, after] {
|
||||
for (column, row, foreground) in [
|
||||
@@ -8721,32 +8988,70 @@ mod tests {
|
||||
(23, 2, Color::DarkGray),
|
||||
] {
|
||||
assert_eq!(
|
||||
buffer[(pane.x + 4 + column, pane.y + 1 + row)].fg,
|
||||
buffer[(pane.x + 4 + column, pane.y + 1 + source_row + row)].fg,
|
||||
foreground
|
||||
);
|
||||
}
|
||||
assert_eq!(buffer[(pane.x + 20, pane.y + 2)].symbol(), "界");
|
||||
assert_eq!(buffer[(pane.x + 22, pane.y + 2)].symbol(), "e\u{301}");
|
||||
assert_eq!(buffer[(pane.x + 26, pane.y + 3)].symbol(), "�");
|
||||
assert_eq!(buffer[(pane.x + 27, pane.y + 3)].symbol(), "n");
|
||||
assert_eq!(
|
||||
buffer[(pane.x + 20, pane.y + 2 + source_row)].symbol(),
|
||||
"界"
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(pane.x + 22, pane.y + 2 + source_row)].symbol(),
|
||||
"e\u{301}"
|
||||
);
|
||||
assert_eq!(buffer[(pane.x + 26, pane.y + 3 + source_row)].symbol(), "�");
|
||||
assert_eq!(buffer[(pane.x + 27, pane.y + 3 + source_row)].symbol(), "n");
|
||||
for x in pane.x + 4..pane.right() - 1 {
|
||||
assert_eq!(buffer[(x, pane.y + 4)].symbol(), " ");
|
||||
assert_eq!(buffer[(x, pane.y + 4)].fg, Color::Reset);
|
||||
assert_eq!(buffer[(x, pane.y + 4 + source_row)].symbol(), " ");
|
||||
assert_eq!(buffer[(x, pane.y + 4 + source_row)].fg, Color::Reset);
|
||||
}
|
||||
}
|
||||
diff.top = 1;
|
||||
diff.left_before = 17;
|
||||
diff.left_after = 16;
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let source_row = panels
|
||||
.diff_wrap
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.scope == [false; 2])
|
||||
.unwrap() as u16;
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), " ");
|
||||
assert_eq!(buffer[(before.x + 4, before.y + 1)].fg, Color::Reset);
|
||||
assert_eq!(buffer[(before.x + 5, before.y + 1)].symbol(), "e\u{301}");
|
||||
assert_eq!(buffer[(before.x + 5, before.y + 1)].fg, Color::Green);
|
||||
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "界");
|
||||
assert_eq!(buffer[(after.x + 4, after.y + 1)].fg, Color::Green);
|
||||
assert_eq!(buffer[(after.x + 6, after.y + 1)].symbol(), "e\u{301}");
|
||||
assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green);
|
||||
assert_eq!(
|
||||
buffer[(before.x + 4, before.y + 1 + source_row)].symbol(),
|
||||
" "
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(before.x + 4, before.y + 1 + source_row)].fg,
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(before.x + 5, before.y + 1 + source_row)].symbol(),
|
||||
"e\u{301}"
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(before.x + 5, before.y + 1 + source_row)].fg,
|
||||
Color::Green
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(after.x + 4, after.y + 1 + source_row)].symbol(),
|
||||
"界"
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(after.x + 4, after.y + 1 + source_row)].fg,
|
||||
Color::Green
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(after.x + 6, after.y + 1 + source_row)].symbol(),
|
||||
"e\u{301}"
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(after.x + 6, after.y + 1 + source_row)].fg,
|
||||
Color::Green
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12398,6 +12703,112 @@ end
|
||||
terminal.get_cursor_position().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_projection_keeps_unified_wrapped_caret_and_selection_source_only() {
|
||||
let before = format!(
|
||||
"fn old_name() {{\n // {}\n}}\nfn second() {{}}\n",
|
||||
"word ".repeat(45)
|
||||
);
|
||||
let after = before.replace("old_name", "new_name");
|
||||
for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] {
|
||||
let mut editor = readonly_diff_editor(syntax_diff_file("scope.rs", &before, &after));
|
||||
if axis == SplitAxis::Horizontal {
|
||||
editor.execute("diff.orientation.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Home);
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::End);
|
||||
let source = editor.cursor();
|
||||
let selections = editor.selections();
|
||||
let text = editor.lines().to_vec();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
for command in [
|
||||
"",
|
||||
"word_wrap.toggle",
|
||||
"diff.view.toggle",
|
||||
"word_wrap.toggle",
|
||||
"diff.view.toggle",
|
||||
] {
|
||||
if !command.is_empty() {
|
||||
editor.execute(command);
|
||||
}
|
||||
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||
assert_eq!(editor.cursor(), source);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.lines(), text);
|
||||
let geometry = views.panels.diff.as_ref().unwrap();
|
||||
let diff = editor.diff().unwrap();
|
||||
let active = usize::from(diff.focus == Focus::After);
|
||||
let area = diff_inner(geometry.regions[active + 1].0);
|
||||
assert!(area.contains(caret));
|
||||
let rows = &views.panels.diff_wrap.as_ref().unwrap().rows;
|
||||
let caret_row = usize::from(caret.y - area.y);
|
||||
assert_eq!(rows[caret_row].scope, [false; 2]);
|
||||
assert_eq!(
|
||||
diff.display_source_row(diff.focus, rows[caret_row].position.display),
|
||||
Some(source.row)
|
||||
);
|
||||
assert!(rows.iter().any(|row| row.scope != [false; 2]));
|
||||
if diff.view_mode == ViewMode::Unified {
|
||||
let labels = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.scope != [false; 2])
|
||||
.map(|(screen, _)| {
|
||||
(area.x..area.right())
|
||||
.map(|x| {
|
||||
terminal.backend().buffer()[(x, area.y + screen as u16)]
|
||||
.symbol()
|
||||
})
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(labels.iter().any(|label| label.contains("− old_name")));
|
||||
assert!(labels.iter().any(|label| label.contains("+ new_name")));
|
||||
}
|
||||
for (screen, row) in rows.iter().take(usize::from(area.height)).enumerate() {
|
||||
if row.scope == [false; 2] {
|
||||
continue;
|
||||
}
|
||||
let y = area.y + screen as u16;
|
||||
for x in area.x..area.right() {
|
||||
assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue);
|
||||
}
|
||||
for x in area.x..area.right() - geometry.text_widths[active] {
|
||||
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_horizontal_scope_diff_keeps_active_source_visible() {
|
||||
let mut editor = readonly_diff_editor(syntax_diff_file(
|
||||
"small.rs",
|
||||
"fn old_name() {}",
|
||||
"fn new_name() {}",
|
||||
));
|
||||
editor.execute("diff.orientation.toggle");
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Char('\t'));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 9)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||
let area = diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0);
|
||||
assert!(
|
||||
area.contains(caret),
|
||||
"source caret {caret:?} outside {area:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
views.panels.diff_wrap.as_ref().unwrap().rows[usize::from(caret.y - area.y)].scope,
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_sequences_navigate_diff_files_and_readonly_panes() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
@@ -12688,7 +13099,17 @@ end
|
||||
let text = editor.lines().to_vec();
|
||||
editor.execute("word_wrap.toggle");
|
||||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||||
assert!(views.panels.diff_wrap.is_none());
|
||||
assert!(!editor.word_wrap());
|
||||
assert!(
|
||||
views
|
||||
.panels
|
||||
.diff_wrap
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.position.continuation == 0)
|
||||
);
|
||||
assert_eq!(editor.selections(), selection);
|
||||
assert_eq!(editor.lines(), text);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
|
||||
Reference in New Issue
Block a user