optimize diff flows
This commit is contained in:
parent
c0d47d2ba8
commit
38146484a5
42 changed files with 14065 additions and 1577 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -13,3 +13,5 @@ tmp/*
|
|||
feat/*
|
||||
.autoresearch/*
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
||||
|
|
@ -216,6 +217,112 @@ pub struct DiffLine {
|
|||
pub text: Arc<str>,
|
||||
}
|
||||
|
||||
pub trait DiffRowProvider {
|
||||
type RowRef: Clone;
|
||||
type SliceIter<'a>: Iterator<Item = Self::RowRef> + 'a
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn len_hint(&self) -> usize;
|
||||
fn row(&self, ix: usize) -> Option<Self::RowRef>;
|
||||
fn slice(&self, start: usize, end: usize) -> Self::SliceIter<'_>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PagedDiffLineProvider {
|
||||
lines: Arc<[DiffLine]>,
|
||||
page_size: usize,
|
||||
pages: Mutex<HashMap<usize, Arc<[DiffLine]>>>,
|
||||
}
|
||||
|
||||
impl PagedDiffLineProvider {
|
||||
pub fn new(lines: Arc<[DiffLine]>, page_size: usize) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
page_size: page_size.max(1),
|
||||
pages: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_vec(lines: Vec<DiffLine>, page_size: usize) -> Self {
|
||||
Self::new(Arc::from(lines), page_size)
|
||||
}
|
||||
|
||||
pub fn cached_page_count(&self) -> usize {
|
||||
self.pages.lock().map(|pages| pages.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn page_bounds(&self, page_ix: usize) -> Option<(usize, usize)> {
|
||||
let start = page_ix.saturating_mul(self.page_size);
|
||||
(start < self.lines.len()).then(|| {
|
||||
let end = start.saturating_add(self.page_size).min(self.lines.len());
|
||||
(start, end)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_page(&self, page_ix: usize) -> Option<Arc<[DiffLine]>> {
|
||||
if let Ok(pages) = self.pages.lock()
|
||||
&& let Some(page) = pages.get(&page_ix)
|
||||
{
|
||||
return Some(Arc::clone(page));
|
||||
}
|
||||
|
||||
let (start, end) = self.page_bounds(page_ix)?;
|
||||
let page = Arc::<[DiffLine]>::from(self.lines[start..end].to_vec());
|
||||
if let Ok(mut pages) = self.pages.lock() {
|
||||
return Some(Arc::clone(
|
||||
pages.entry(page_ix).or_insert_with(|| Arc::clone(&page)),
|
||||
));
|
||||
}
|
||||
Some(page)
|
||||
}
|
||||
}
|
||||
|
||||
impl DiffRowProvider for PagedDiffLineProvider {
|
||||
type RowRef = DiffLine;
|
||||
type SliceIter<'a>
|
||||
= std::vec::IntoIter<DiffLine>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn len_hint(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
fn row(&self, ix: usize) -> Option<Self::RowRef> {
|
||||
if ix >= self.lines.len() {
|
||||
return None;
|
||||
}
|
||||
let page_ix = ix / self.page_size;
|
||||
let row_ix = ix % self.page_size;
|
||||
let page = self.load_page(page_ix)?;
|
||||
page.get(row_ix).cloned()
|
||||
}
|
||||
|
||||
fn slice(&self, start: usize, end: usize) -> Self::SliceIter<'_> {
|
||||
if start >= end || start >= self.lines.len() {
|
||||
return Vec::new().into_iter();
|
||||
}
|
||||
let end = end.min(self.lines.len());
|
||||
let mut rows = Vec::with_capacity(end - start);
|
||||
let mut ix = start;
|
||||
while ix < end {
|
||||
let page_ix = ix / self.page_size;
|
||||
let page_row_ix = ix % self.page_size;
|
||||
let Some(page) = self.load_page(page_ix) else {
|
||||
break;
|
||||
};
|
||||
if let Some(line) = page.get(page_row_ix) {
|
||||
rows.push(line.clone());
|
||||
ix += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
rows.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DiffLineKind {
|
||||
Header,
|
||||
|
|
@ -226,45 +333,71 @@ pub enum DiffLineKind {
|
|||
}
|
||||
|
||||
impl Diff {
|
||||
pub fn from_unified(target: DiffTarget, text: &str) -> Self {
|
||||
let approx_lines = text
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.filter(|&&b| b == b'\n')
|
||||
.count()
|
||||
.saturating_add(1);
|
||||
let mut lines = Vec::with_capacity(approx_lines);
|
||||
fn classify_unified_line(raw: &str) -> DiffLineKind {
|
||||
if raw.starts_with("@@") {
|
||||
DiffLineKind::Hunk
|
||||
} else if raw.starts_with("diff ")
|
||||
|| raw.starts_with("index ")
|
||||
|| raw.starts_with("--- ")
|
||||
|| raw.starts_with("+++ ")
|
||||
|| raw.starts_with("new file mode ")
|
||||
|| raw.starts_with("deleted file mode ")
|
||||
|| raw.starts_with("similarity index ")
|
||||
|| raw.starts_with("rename from ")
|
||||
|| raw.starts_with("rename to ")
|
||||
|| raw.starts_with("Binary files ")
|
||||
{
|
||||
DiffLineKind::Header
|
||||
} else if raw.starts_with('+') && !raw.starts_with("+++ ") {
|
||||
DiffLineKind::Add
|
||||
} else if raw.starts_with('-') && !raw.starts_with("---") {
|
||||
DiffLineKind::Remove
|
||||
} else {
|
||||
DiffLineKind::Context
|
||||
}
|
||||
}
|
||||
|
||||
for raw in text.lines() {
|
||||
let kind = if raw.starts_with("@@") {
|
||||
DiffLineKind::Hunk
|
||||
} else if raw.starts_with("diff ")
|
||||
|| raw.starts_with("index ")
|
||||
|| raw.starts_with("--- ")
|
||||
|| raw.starts_with("+++ ")
|
||||
|| raw.starts_with("new file mode ")
|
||||
|| raw.starts_with("deleted file mode ")
|
||||
|| raw.starts_with("similarity index ")
|
||||
|| raw.starts_with("rename from ")
|
||||
|| raw.starts_with("rename to ")
|
||||
|| raw.starts_with("Binary files ")
|
||||
{
|
||||
DiffLineKind::Header
|
||||
} else if raw.starts_with('+') && !raw.starts_with("+++ ") {
|
||||
DiffLineKind::Add
|
||||
} else if raw.starts_with('-') && !raw.starts_with("---") {
|
||||
DiffLineKind::Remove
|
||||
} else {
|
||||
DiffLineKind::Context
|
||||
};
|
||||
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
pub fn from_unified_iter<'a>(
|
||||
target: DiffTarget,
|
||||
lines: impl IntoIterator<Item = &'a str>,
|
||||
) -> Self {
|
||||
let mut out = Vec::new();
|
||||
for raw in lines {
|
||||
out.push(DiffLine {
|
||||
kind: Self::classify_unified_line(raw),
|
||||
text: raw.into(),
|
||||
});
|
||||
}
|
||||
Self { target, lines: out }
|
||||
}
|
||||
|
||||
Self { target, lines }
|
||||
pub fn from_unified_reader<R: std::io::BufRead>(
|
||||
target: DiffTarget,
|
||||
mut reader: R,
|
||||
) -> std::io::Result<Self> {
|
||||
let mut lines = Vec::new();
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
let read = reader.read_line(&mut buf)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let raw = buf.trim_end_matches(['\n', '\r']);
|
||||
lines.push(DiffLine {
|
||||
kind: Self::classify_unified_line(raw),
|
||||
text: raw.into(),
|
||||
});
|
||||
}
|
||||
Ok(Self { target, lines })
|
||||
}
|
||||
|
||||
pub fn from_unified(target: DiffTarget, text: &str) -> Self {
|
||||
Self::from_unified_iter(target, text.lines())
|
||||
}
|
||||
|
||||
pub fn paged_lines(&self, page_size: usize) -> PagedDiffLineProvider {
|
||||
PagedDiffLineProvider::new(Arc::from(self.lines.clone()), page_size)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +431,9 @@ pub struct LogCursor {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SubmoduleStatus;
|
||||
use super::{Diff, DiffArea, DiffLineKind, DiffRowProvider, DiffTarget, SubmoduleStatus};
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn submodule_status_maps_known_git_markers() {
|
||||
|
|
@ -326,4 +461,87 @@ mod tests {
|
|||
assert_eq!(status, SubmoduleStatus::Unknown('M'));
|
||||
assert_eq!(status.git_status_marker(), 'M');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_reader_matches_string_parser() {
|
||||
let target = DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("src/main.rs"),
|
||||
area: DiffArea::Unstaged,
|
||||
};
|
||||
let unified = "\
|
||||
diff --git a/src/main.rs b/src/main.rs\n\
|
||||
index 1111111..2222222 100644\n\
|
||||
--- a/src/main.rs\n\
|
||||
+++ b/src/main.rs\n\
|
||||
@@ -1,2 +1,3 @@\n\
|
||||
fn main() {\n\
|
||||
- println!(\"old\");\n\
|
||||
+ println!(\"new\");\n\
|
||||
+ println!(\"extra\");\n\
|
||||
}\n";
|
||||
|
||||
let from_text = Diff::from_unified(target.clone(), unified);
|
||||
let from_reader = Diff::from_unified_reader(target, Cursor::new(unified.as_bytes()))
|
||||
.expect("reader parse should succeed");
|
||||
|
||||
assert_eq!(from_reader, from_text);
|
||||
assert_eq!(from_reader.lines[0].kind, DiffLineKind::Header);
|
||||
assert_eq!(from_reader.lines[4].kind, DiffLineKind::Hunk);
|
||||
assert_eq!(from_reader.lines[6].kind, DiffLineKind::Remove);
|
||||
assert_eq!(from_reader.lines[7].kind, DiffLineKind::Add);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_reader_trims_crlf_line_endings() {
|
||||
let target = DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("README.md"),
|
||||
area: DiffArea::Unstaged,
|
||||
};
|
||||
let unified = "\
|
||||
@@ -1 +1 @@\r\n\
|
||||
-old\r\n\
|
||||
+new\r\n";
|
||||
|
||||
let diff = Diff::from_unified_reader(target, Cursor::new(unified.as_bytes()))
|
||||
.expect("reader parse should succeed");
|
||||
assert_eq!(diff.lines.len(), 3);
|
||||
assert_eq!(diff.lines[0].kind, DiffLineKind::Hunk);
|
||||
assert_eq!(diff.lines[1].text.as_ref(), "-old");
|
||||
assert_eq!(diff.lines[2].text.as_ref(), "+new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paged_provider_loads_pages_on_demand() {
|
||||
let target = DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("src/lib.rs"),
|
||||
area: DiffArea::Unstaged,
|
||||
};
|
||||
let unified = "\
|
||||
diff --git a/src/lib.rs b/src/lib.rs\n\
|
||||
@@ -1,4 +1,4 @@\n\
|
||||
old1\n\
|
||||
-old2\n\
|
||||
+new2\n\
|
||||
old3\n";
|
||||
let diff = Diff::from_unified(target, unified);
|
||||
let provider = diff.paged_lines(2);
|
||||
|
||||
assert_eq!(provider.cached_page_count(), 0);
|
||||
assert_eq!(provider.len_hint(), diff.lines.len());
|
||||
|
||||
let line = provider.row(3).expect("line 3 should exist");
|
||||
assert_eq!(line.text.as_ref(), "-old2");
|
||||
assert_eq!(provider.cached_page_count(), 1);
|
||||
|
||||
let line = provider.row(0).expect("line 0 should exist");
|
||||
assert_eq!(line.text.as_ref(), "diff --git a/src/lib.rs b/src/lib.rs");
|
||||
assert_eq!(provider.cached_page_count(), 2);
|
||||
|
||||
let slice = provider
|
||||
.slice(2, 5)
|
||||
.map(|line| line.text.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(slice, vec!["old1", "-old2", "+new2"]);
|
||||
assert_eq!(provider.cached_page_count(), 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,14 @@ pub trait GitRepository: Send + Sync {
|
|||
Ok(None)
|
||||
}
|
||||
fn diff_unified(&self, target: &DiffTarget) -> Result<String>;
|
||||
/// Load and parse unified diff rows for the target.
|
||||
///
|
||||
/// Default implementation goes through `diff_unified`; backends may
|
||||
/// override for streaming parsing to avoid large monolithic allocations.
|
||||
fn diff_parsed(&self, target: &DiffTarget) -> Result<Diff> {
|
||||
self.diff_unified(target)
|
||||
.map(|text| Diff::from_unified(target.clone(), &text))
|
||||
}
|
||||
fn diff_file_text(&self, _target: &DiffTarget) -> Result<Option<FileDiffText>> {
|
||||
Err(Error::new(ErrorKind::Unsupported(
|
||||
"file diff view is not implemented for this backend",
|
||||
|
|
|
|||
|
|
@ -1,83 +1,119 @@
|
|||
use super::GixRepo;
|
||||
use crate::util::run_git_capture;
|
||||
use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};
|
||||
use gitcomet_core::domain::{DiffArea, DiffTarget, FileConflictKind, FileDiffImage, FileDiffText};
|
||||
use gitcomet_core::domain::{
|
||||
Diff, DiffArea, DiffTarget, FileConflictKind, FileDiffImage, FileDiffText,
|
||||
};
|
||||
use gitcomet_core::error::{Error, ErrorKind};
|
||||
use gitcomet_core::services::{ConflictFileStages, Result, decode_utf8_optional};
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::str;
|
||||
|
||||
impl GixRepo {
|
||||
pub(super) fn diff_unified_impl(&self, target: &DiffTarget) -> Result<String> {
|
||||
fn build_unified_diff_command(&self, target: &DiffTarget) -> Command {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(&self.spec.workdir)
|
||||
.arg("-c")
|
||||
.arg("color.ui=false")
|
||||
.arg("--no-pager");
|
||||
|
||||
match target {
|
||||
DiffTarget::WorkingTree { path, area } => {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(&self.spec.workdir)
|
||||
.arg("-c")
|
||||
.arg("color.ui=false")
|
||||
.arg("--no-pager")
|
||||
.arg("diff")
|
||||
.arg("--no-ext-diff");
|
||||
|
||||
cmd.arg("diff").arg("--no-ext-diff");
|
||||
if matches!(area, DiffArea::Staged) {
|
||||
cmd.arg("--cached");
|
||||
}
|
||||
|
||||
cmd.arg("--").arg(path);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
|
||||
|
||||
let ok_exit = output.status.success() || output.status.code() == Some(1);
|
||||
if !ok_exit {
|
||||
let stderr = str::from_utf8(&output.stderr).unwrap_or("<non-utf8 stderr>");
|
||||
return Err(Error::new(ErrorKind::Backend(format!(
|
||||
"git diff failed: {stderr}"
|
||||
))));
|
||||
}
|
||||
|
||||
String::from_utf8(output.stdout).map_err(|_| {
|
||||
Error::new(ErrorKind::Backend(
|
||||
"git diff produced non-UTF-8 output".to_string(),
|
||||
))
|
||||
})
|
||||
}
|
||||
DiffTarget::Commit { commit_id, path } => {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(&self.spec.workdir)
|
||||
.arg("-c")
|
||||
.arg("color.ui=false")
|
||||
.arg("--no-pager")
|
||||
.arg("show")
|
||||
cmd.arg("show")
|
||||
.arg("--no-ext-diff")
|
||||
.arg("--pretty=format:")
|
||||
.arg(commit_id.as_ref());
|
||||
|
||||
if let Some(path) = path {
|
||||
cmd.arg("--").arg(path);
|
||||
}
|
||||
|
||||
run_git_capture(cmd, "git show --pretty=format:")
|
||||
}
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
pub(super) fn diff_unified_impl(&self, target: &DiffTarget) -> Result<String> {
|
||||
let output = self
|
||||
.build_unified_diff_command(target)
|
||||
.output()
|
||||
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
|
||||
|
||||
let ok_exit = output.status.success() || output.status.code() == Some(1);
|
||||
if !ok_exit {
|
||||
let stderr = str::from_utf8(&output.stderr).unwrap_or("<non-utf8 stderr>");
|
||||
return Err(Error::new(ErrorKind::Backend(format!(
|
||||
"git diff failed: {stderr}"
|
||||
))));
|
||||
}
|
||||
|
||||
String::from_utf8(output.stdout).map_err(|_| {
|
||||
Error::new(ErrorKind::Backend(
|
||||
"git diff produced non-UTF-8 output".to_string(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn diff_parsed_impl(&self, target: &DiffTarget) -> Result<Diff> {
|
||||
let mut cmd = self.build_unified_diff_command(target);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
Error::new(ErrorKind::Backend(
|
||||
"git diff did not provide a stdout pipe".to_string(),
|
||||
))
|
||||
})?;
|
||||
let mut stderr = child.stderr.take().ok_or_else(|| {
|
||||
Error::new(ErrorKind::Backend(
|
||||
"git diff did not provide a stderr pipe".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
// Drain stderr concurrently so very large stderr output can't block stdout parsing.
|
||||
let stderr_reader = std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut bytes);
|
||||
bytes
|
||||
});
|
||||
|
||||
let diff = Diff::from_unified_reader(target.clone(), BufReader::new(stdout))
|
||||
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
|
||||
let stderr_bytes = stderr_reader.join().unwrap_or_default();
|
||||
|
||||
let ok_exit = status.success() || status.code() == Some(1);
|
||||
if !ok_exit {
|
||||
let stderr = str::from_utf8(&stderr_bytes).unwrap_or("<non-utf8 stderr>");
|
||||
return Err(Error::new(ErrorKind::Backend(format!(
|
||||
"git diff failed: {stderr}"
|
||||
))));
|
||||
}
|
||||
|
||||
Ok(diff)
|
||||
}
|
||||
|
||||
pub(super) fn diff_file_text_impl(&self, target: &DiffTarget) -> Result<Option<FileDiffText>> {
|
||||
match target {
|
||||
DiffTarget::WorkingTree { path, area } => {
|
||||
if matches!(area, DiffArea::Unstaged) {
|
||||
let full_path = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
self.spec.workdir.join(path)
|
||||
};
|
||||
if std::fs::metadata(&full_path).is_ok_and(|m| m.is_dir()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let full_path = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
self.spec.workdir.join(path)
|
||||
};
|
||||
if std::fs::metadata(&full_path).is_ok_and(|m| m.is_dir()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let repo = self._repo.to_thread_local();
|
||||
|
|
@ -166,15 +202,13 @@ impl GixRepo {
|
|||
) -> Result<Option<FileDiffImage>> {
|
||||
match target {
|
||||
DiffTarget::WorkingTree { path, area } => {
|
||||
if matches!(area, DiffArea::Unstaged) {
|
||||
let full_path = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
self.spec.workdir.join(path)
|
||||
};
|
||||
if std::fs::metadata(&full_path).is_ok_and(|m| m.is_dir()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let full_path = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
self.spec.workdir.join(path)
|
||||
};
|
||||
if std::fs::metadata(&full_path).is_ok_and(|m| m.is_dir()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let repo = self._repo.to_thread_local();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use gitcomet_core::conflict_session::ConflictSession;
|
||||
use gitcomet_core::domain::{
|
||||
Branch, CommitDetails, CommitId, DiffTarget, FileDiffImage, FileDiffText, LogCursor, LogPage,
|
||||
ReflogEntry, Remote, RemoteBranch, RemoteTag, RepoSpec, RepoStatus, StashEntry, Submodule, Tag,
|
||||
UpstreamDivergence, Worktree,
|
||||
Branch, CommitDetails, CommitId, Diff, DiffTarget, FileDiffImage, FileDiffText, LogCursor,
|
||||
LogPage, ReflogEntry, Remote, RemoteBranch, RemoteTag, RepoSpec, RepoStatus, StashEntry,
|
||||
Submodule, Tag, UpstreamDivergence, Worktree,
|
||||
};
|
||||
use gitcomet_core::services::{
|
||||
BlameLine, CommandOutput, ConflictFileStages, ConflictSide, GitRepository, MergetoolResult,
|
||||
|
|
@ -118,6 +118,10 @@ impl GitRepository for GixRepo {
|
|||
self.diff_unified_impl(target)
|
||||
}
|
||||
|
||||
fn diff_parsed(&self, target: &DiffTarget) -> Result<Diff> {
|
||||
self.diff_parsed_impl(target)
|
||||
}
|
||||
|
||||
fn diff_file_text(&self, target: &DiffTarget) -> Result<Option<FileDiffText>> {
|
||||
self.diff_file_text_impl(target)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use gitcomet_core::domain::{
|
|||
use gitcomet_core::error::{Error, ErrorKind};
|
||||
use gitcomet_core::services::Result;
|
||||
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
impl GixRepo {
|
||||
|
|
@ -144,6 +144,8 @@ impl GixRepo {
|
|||
}
|
||||
}
|
||||
|
||||
supplement_gitlink_status_from_porcelain(&self.spec.workdir, &mut staged, &mut unstaged)?;
|
||||
|
||||
fn kind_priority(kind: FileStatusKind) -> u8 {
|
||||
match kind {
|
||||
FileStatusKind::Conflicted => 5,
|
||||
|
|
@ -318,10 +320,119 @@ fn map_directory_entry_status(status: gix::dir::entry::Status) -> Option<FileSta
|
|||
}
|
||||
}
|
||||
|
||||
fn map_porcelain_v2_status_char(ch: char) -> Option<FileStatusKind> {
|
||||
match ch {
|
||||
'M' | 'T' => Some(FileStatusKind::Modified),
|
||||
'A' => Some(FileStatusKind::Added),
|
||||
'D' => Some(FileStatusKind::Deleted),
|
||||
'R' => Some(FileStatusKind::Renamed),
|
||||
'U' => Some(FileStatusKind::Conflicted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_status_entry(entries: &mut Vec<FileStatus>, path: PathBuf, kind: FileStatusKind) {
|
||||
if entries.iter().any(|e| e.path == path && e.kind == kind) {
|
||||
return;
|
||||
}
|
||||
entries.push(FileStatus {
|
||||
path,
|
||||
kind,
|
||||
conflict: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_porcelain_v2_gitlink_status_record(
|
||||
record: &str,
|
||||
staged: &mut Vec<FileStatus>,
|
||||
unstaged: &mut Vec<FileStatus>,
|
||||
) {
|
||||
let mut parts = record.splitn(9, ' ');
|
||||
let Some(kind) = parts.next() else {
|
||||
return;
|
||||
};
|
||||
if kind != "1" {
|
||||
return;
|
||||
}
|
||||
|
||||
let xy = parts.next().unwrap_or_default();
|
||||
let _sub = parts.next().unwrap_or_default();
|
||||
let m_head = parts.next().unwrap_or_default();
|
||||
let m_index = parts.next().unwrap_or_default();
|
||||
let m_worktree = parts.next().unwrap_or_default();
|
||||
let _h_head = parts.next().unwrap_or_default();
|
||||
let _h_index = parts.next().unwrap_or_default();
|
||||
let path = parts.next().unwrap_or_default();
|
||||
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_gitlink = m_head == "160000" || m_index == "160000" || m_worktree == "160000";
|
||||
if !is_gitlink {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut xy_chars = xy.chars();
|
||||
let x = xy_chars.next().unwrap_or('.');
|
||||
let y = xy_chars.next().unwrap_or('.');
|
||||
let path = PathBuf::from(path);
|
||||
|
||||
if let Some(kind) = map_porcelain_v2_status_char(x) {
|
||||
push_status_entry(staged, path.clone(), kind);
|
||||
}
|
||||
if let Some(kind) = map_porcelain_v2_status_char(y) {
|
||||
push_status_entry(unstaged, path, kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn supplement_gitlink_status_from_porcelain(
|
||||
workdir: &Path,
|
||||
staged: &mut Vec<FileStatus>,
|
||||
unstaged: &mut Vec<FileStatus>,
|
||||
) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workdir)
|
||||
.arg("--no-optional-locks")
|
||||
.arg("status")
|
||||
.arg("--porcelain=v2")
|
||||
.arg("-z")
|
||||
.arg("--ignore-submodules=none")
|
||||
.output()
|
||||
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut records = output.stdout.split(|b| *b == 0).peekable();
|
||||
while let Some(record) = records.next() {
|
||||
if record.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match record[0] {
|
||||
b'1' => {
|
||||
if let Ok(text) = std::str::from_utf8(record) {
|
||||
apply_porcelain_v2_gitlink_status_record(text, staged, unstaged);
|
||||
}
|
||||
}
|
||||
b'2' => {
|
||||
// Rename/copy records carry an additional NUL-separated path.
|
||||
let _ = records.next();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
collect_unmerged_conflicts, conflict_kind_from_stage_mask, map_directory_entry_status,
|
||||
apply_porcelain_v2_gitlink_status_record, collect_unmerged_conflicts,
|
||||
conflict_kind_from_stage_mask, map_directory_entry_status,
|
||||
};
|
||||
use gitcomet_core::domain::{FileConflictKind, FileStatusKind};
|
||||
use rustc_hash::FxHashMap as HashMap;
|
||||
|
|
@ -447,4 +558,39 @@ mod tests {
|
|||
);
|
||||
assert_eq!(map_directory_entry_status(Status::Pruned), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn porcelain_gitlink_record_maps_committed_unstaged_modification() {
|
||||
let mut staged = Vec::new();
|
||||
let mut unstaged = Vec::new();
|
||||
apply_porcelain_v2_gitlink_status_record(
|
||||
"1 .M SC.. 160000 160000 160000 1111111111111111111111111111111111111111 1111111111111111111111111111111111111111 chess3",
|
||||
&mut staged,
|
||||
&mut unstaged,
|
||||
);
|
||||
|
||||
assert!(staged.is_empty());
|
||||
assert_eq!(unstaged.len(), 1);
|
||||
assert_eq!(unstaged[0].path, PathBuf::from("chess3"));
|
||||
assert_eq!(unstaged[0].kind, FileStatusKind::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn porcelain_gitlink_record_maps_added_and_unstaged_modified() {
|
||||
let mut staged = Vec::new();
|
||||
let mut unstaged = Vec::new();
|
||||
apply_porcelain_v2_gitlink_status_record(
|
||||
"1 AM SC.. 000000 160000 160000 0000000000000000000000000000000000000000 2222222222222222222222222222222222222222 chess3",
|
||||
&mut staged,
|
||||
&mut unstaged,
|
||||
);
|
||||
|
||||
assert_eq!(staged.len(), 1);
|
||||
assert_eq!(staged[0].path, PathBuf::from("chess3"));
|
||||
assert_eq!(staged[0].kind, FileStatusKind::Added);
|
||||
|
||||
assert_eq!(unstaged.len(), 1);
|
||||
assert_eq!(unstaged[0].path, PathBuf::from("chess3"));
|
||||
assert_eq!(unstaged[0].kind, FileStatusKind::Modified);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,11 @@ fn create_askpass_script() -> Result<AskPassScript> {
|
|||
Ok(AskPassScript { _dir: dir, path })
|
||||
}
|
||||
|
||||
fn configure_git_auth_prompt(cmd: &mut Command, auth: &StagedGitAuth, askpass: &AskPassScript) {
|
||||
fn configure_git_auth_prompt(
|
||||
cmd: &mut Command,
|
||||
auth: Option<&StagedGitAuth>,
|
||||
askpass: &AskPassScript,
|
||||
) {
|
||||
cmd.env("GIT_ASKPASS", &askpass.path);
|
||||
cmd.env("SSH_ASKPASS", &askpass.path);
|
||||
cmd.env("SSH_ASKPASS_REQUIRE", "force");
|
||||
|
|
@ -137,29 +141,32 @@ fn configure_git_auth_prompt(cmd: &mut Command, auth: &StagedGitAuth, askpass: &
|
|||
cmd.env("DISPLAY", "gitcomet:0");
|
||||
}
|
||||
|
||||
let kind = match auth.kind {
|
||||
GitAuthKind::UsernamePassword => GITCOMET_AUTH_KIND_USERNAME_PASSWORD,
|
||||
GitAuthKind::Passphrase => GITCOMET_AUTH_KIND_PASSPHRASE,
|
||||
};
|
||||
cmd.env(GITCOMET_AUTH_KIND_ENV, kind);
|
||||
if let Some(username) = &auth.username {
|
||||
cmd.env(GITCOMET_AUTH_USERNAME_ENV, username);
|
||||
if let Some(auth) = auth {
|
||||
let kind = match auth.kind {
|
||||
GitAuthKind::UsernamePassword => GITCOMET_AUTH_KIND_USERNAME_PASSWORD,
|
||||
GitAuthKind::Passphrase => GITCOMET_AUTH_KIND_PASSPHRASE,
|
||||
};
|
||||
cmd.env(GITCOMET_AUTH_KIND_ENV, kind);
|
||||
if let Some(username) = &auth.username {
|
||||
cmd.env(GITCOMET_AUTH_USERNAME_ENV, username);
|
||||
} else {
|
||||
cmd.env_remove(GITCOMET_AUTH_USERNAME_ENV);
|
||||
}
|
||||
cmd.env(GITCOMET_AUTH_SECRET_ENV, &auth.secret);
|
||||
} else {
|
||||
cmd.env_remove(GITCOMET_AUTH_KIND_ENV);
|
||||
cmd.env_remove(GITCOMET_AUTH_USERNAME_ENV);
|
||||
cmd.env_remove(GITCOMET_AUTH_SECRET_ENV);
|
||||
}
|
||||
cmd.env(GITCOMET_AUTH_SECRET_ENV, &auth.secret);
|
||||
}
|
||||
|
||||
fn run_git_output_with_timeout(mut cmd: Command, label: &str) -> Result<Output> {
|
||||
configure_non_interactive_git(&mut cmd);
|
||||
let askpass_script = if command_may_require_auth(&cmd) {
|
||||
take_pending_git_auth()
|
||||
.map(|auth| {
|
||||
let script = create_askpass_script()?;
|
||||
configure_git_auth_prompt(&mut cmd, &auth, &script);
|
||||
Ok(script)
|
||||
})
|
||||
.transpose()?
|
||||
let auth = take_pending_git_auth();
|
||||
let script = create_askpass_script()?;
|
||||
configure_git_auth_prompt(&mut cmd, auth.as_ref(), &script);
|
||||
Some(script)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -1157,7 +1164,7 @@ mod tests {
|
|||
secret: "secret-token".to_string(),
|
||||
};
|
||||
|
||||
configure_git_auth_prompt(&mut cmd, &auth, &askpass);
|
||||
configure_git_auth_prompt(&mut cmd, Some(&auth), &askpass);
|
||||
|
||||
let askpass_path = askpass
|
||||
.path
|
||||
|
|
@ -1208,7 +1215,7 @@ mod tests {
|
|||
secret: "ssh-passphrase".to_string(),
|
||||
};
|
||||
|
||||
configure_git_auth_prompt(&mut cmd, &auth, &askpass);
|
||||
configure_git_auth_prompt(&mut cmd, Some(&auth), &askpass);
|
||||
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, GITCOMET_AUTH_KIND_ENV).as_deref(),
|
||||
|
|
@ -1220,4 +1227,28 @@ mod tests {
|
|||
Some("ssh-passphrase")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_git_auth_prompt_without_staged_auth_clears_auth_env() {
|
||||
let askpass = create_askpass_script().expect("askpass script creation");
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.env(GITCOMET_AUTH_KIND_ENV, "legacy-kind");
|
||||
cmd.env(GITCOMET_AUTH_USERNAME_ENV, "legacy-user");
|
||||
cmd.env(GITCOMET_AUTH_SECRET_ENV, "legacy-secret");
|
||||
|
||||
configure_git_auth_prompt(&mut cmd, None, &askpass);
|
||||
|
||||
let askpass_path = askpass
|
||||
.path
|
||||
.to_str()
|
||||
.expect("temporary askpass path should be unicode")
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, "GIT_ASKPASS").as_deref(),
|
||||
Some(askpass_path.as_str())
|
||||
);
|
||||
assert!(command_env_removed(&cmd, GITCOMET_AUTH_KIND_ENV));
|
||||
assert!(command_env_removed(&cmd, GITCOMET_AUTH_USERNAME_ENV));
|
||||
assert!(command_env_removed(&cmd, GITCOMET_AUTH_SECRET_ENV));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,16 @@ fn diff_file_text_returns_none_for_directories() {
|
|||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
run_git(repo, &["add", "dir/a.txt"]);
|
||||
let staged_result = opened
|
||||
.diff_file_text(&DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("dir"),
|
||||
area: DiffArea::Staged,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(staged_result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1209,6 +1219,160 @@ fn diff_file_image_returns_none_for_directories() {
|
|||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
run_git(repo, &["add", "dir/a.png"]);
|
||||
let staged_result = opened
|
||||
.diff_file_image(&DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("dir"),
|
||||
area: DiffArea::Staged,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(staged_result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlink_added_and_unstaged_modified_reports_expected_status_and_diff() {
|
||||
if !require_git_shell_for_status_integration_tests() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = dir.path();
|
||||
let nested = repo.join("chess3");
|
||||
|
||||
run_git(repo, &["init"]);
|
||||
run_git(repo, &["config", "user.email", "you@example.com"]);
|
||||
run_git(repo, &["config", "user.name", "You"]);
|
||||
run_git(repo, &["config", "commit.gpgsign", "false"]);
|
||||
|
||||
std::fs::create_dir_all(&nested).expect("create nested repo path");
|
||||
run_git(&nested, &["init"]);
|
||||
run_git(&nested, &["config", "user.email", "you@example.com"]);
|
||||
run_git(&nested, &["config", "user.name", "You"]);
|
||||
run_git(&nested, &["config", "commit.gpgsign", "false"]);
|
||||
|
||||
write(&nested, "file.txt", "one\n");
|
||||
run_git(&nested, &["add", "file.txt"]);
|
||||
run_git(
|
||||
&nested,
|
||||
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c1"],
|
||||
);
|
||||
|
||||
run_git(repo, &["add", "chess3"]);
|
||||
|
||||
write(&nested, "file.txt", "one\ntwo\n");
|
||||
run_git(&nested, &["add", "file.txt"]);
|
||||
run_git(
|
||||
&nested,
|
||||
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c2"],
|
||||
);
|
||||
|
||||
let backend = GixBackend;
|
||||
let opened = backend.open(repo).unwrap();
|
||||
|
||||
let status = opened.status().unwrap();
|
||||
assert!(
|
||||
status
|
||||
.staged
|
||||
.iter()
|
||||
.any(|e| e.path == Path::new("chess3") && e.kind == FileStatusKind::Added),
|
||||
"expected staged Added gitlink entry; status={status:?}"
|
||||
);
|
||||
assert!(
|
||||
status
|
||||
.unstaged
|
||||
.iter()
|
||||
.any(|e| e.path == Path::new("chess3") && e.kind == FileStatusKind::Modified),
|
||||
"expected unstaged Modified gitlink entry; status={status:?}"
|
||||
);
|
||||
|
||||
let diff = opened
|
||||
.diff_unified(&DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("chess3"),
|
||||
area: DiffArea::Unstaged,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
diff.contains("Subproject commit"),
|
||||
"expected unstaged gitlink unified diff to include subproject commit line; diff={diff}"
|
||||
);
|
||||
|
||||
let file_text = opened
|
||||
.diff_file_text(&DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("chess3"),
|
||||
area: DiffArea::Unstaged,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
file_text.is_none(),
|
||||
"expected no direct file text payload for directory-backed gitlink target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_gitlink_unstaged_modified_reports_modified_status_and_diff() {
|
||||
if !require_git_shell_for_status_integration_tests() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = dir.path();
|
||||
let nested = repo.join("chess3");
|
||||
|
||||
run_git(repo, &["init"]);
|
||||
run_git(repo, &["config", "user.email", "you@example.com"]);
|
||||
run_git(repo, &["config", "user.name", "You"]);
|
||||
run_git(repo, &["config", "commit.gpgsign", "false"]);
|
||||
|
||||
std::fs::create_dir_all(&nested).expect("create nested repo path");
|
||||
run_git(&nested, &["init"]);
|
||||
run_git(&nested, &["config", "user.email", "you@example.com"]);
|
||||
run_git(&nested, &["config", "user.name", "You"]);
|
||||
run_git(&nested, &["config", "commit.gpgsign", "false"]);
|
||||
|
||||
write(&nested, "file.txt", "one\n");
|
||||
run_git(&nested, &["add", "file.txt"]);
|
||||
run_git(
|
||||
&nested,
|
||||
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c1"],
|
||||
);
|
||||
|
||||
run_git(repo, &["add", "chess3"]);
|
||||
run_git(
|
||||
repo,
|
||||
&["-c", "commit.gpgsign=false", "commit", "-m", "add gitlink"],
|
||||
);
|
||||
|
||||
write(&nested, "file.txt", "one\ntwo\n");
|
||||
run_git(&nested, &["add", "file.txt"]);
|
||||
run_git(
|
||||
&nested,
|
||||
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c2"],
|
||||
);
|
||||
|
||||
let backend = GixBackend;
|
||||
let opened = backend.open(repo).unwrap();
|
||||
|
||||
let status = opened.status().unwrap();
|
||||
assert!(
|
||||
status
|
||||
.unstaged
|
||||
.iter()
|
||||
.any(|e| e.path == Path::new("chess3") && e.kind == FileStatusKind::Modified),
|
||||
"expected unstaged Modified gitlink entry after nested repo advances; status={status:?}"
|
||||
);
|
||||
|
||||
let diff = opened
|
||||
.diff_unified(&DiffTarget::WorkingTree {
|
||||
path: PathBuf::from("chess3"),
|
||||
area: DiffArea::Unstaged,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
diff.contains("Subproject commit"),
|
||||
"expected unstaged gitlink unified diff to include subproject commit line; diff={diff}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -192,7 +192,11 @@ fn create_askpass_script() -> Result<AskPassScript, Error> {
|
|||
Ok(AskPassScript { _dir: dir, path })
|
||||
}
|
||||
|
||||
fn configure_clone_auth_prompt(cmd: &mut Command, auth: &StagedGitAuth, askpass: &AskPassScript) {
|
||||
fn configure_clone_auth_prompt(
|
||||
cmd: &mut Command,
|
||||
auth: Option<&StagedGitAuth>,
|
||||
askpass: &AskPassScript,
|
||||
) {
|
||||
cmd.env("GIT_ASKPASS", &askpass.path);
|
||||
cmd.env("SSH_ASKPASS", &askpass.path);
|
||||
cmd.env("SSH_ASKPASS_REQUIRE", "force");
|
||||
|
|
@ -200,17 +204,23 @@ fn configure_clone_auth_prompt(cmd: &mut Command, auth: &StagedGitAuth, askpass:
|
|||
cmd.env("DISPLAY", "gitcomet:0");
|
||||
}
|
||||
|
||||
let kind = match auth.kind {
|
||||
GitAuthKind::UsernamePassword => GITCOMET_AUTH_KIND_USERNAME_PASSWORD,
|
||||
GitAuthKind::Passphrase => GITCOMET_AUTH_KIND_PASSPHRASE,
|
||||
};
|
||||
cmd.env(GITCOMET_AUTH_KIND_ENV, kind);
|
||||
if let Some(username) = &auth.username {
|
||||
cmd.env(GITCOMET_AUTH_USERNAME_ENV, username);
|
||||
if let Some(auth) = auth {
|
||||
let kind = match auth.kind {
|
||||
GitAuthKind::UsernamePassword => GITCOMET_AUTH_KIND_USERNAME_PASSWORD,
|
||||
GitAuthKind::Passphrase => GITCOMET_AUTH_KIND_PASSPHRASE,
|
||||
};
|
||||
cmd.env(GITCOMET_AUTH_KIND_ENV, kind);
|
||||
if let Some(username) = &auth.username {
|
||||
cmd.env(GITCOMET_AUTH_USERNAME_ENV, username);
|
||||
} else {
|
||||
cmd.env_remove(GITCOMET_AUTH_USERNAME_ENV);
|
||||
}
|
||||
cmd.env(GITCOMET_AUTH_SECRET_ENV, &auth.secret);
|
||||
} else {
|
||||
cmd.env_remove(GITCOMET_AUTH_KIND_ENV);
|
||||
cmd.env_remove(GITCOMET_AUTH_USERNAME_ENV);
|
||||
cmd.env_remove(GITCOMET_AUTH_SECRET_ENV);
|
||||
}
|
||||
cmd.env(GITCOMET_AUTH_SECRET_ENV, &auth.secret);
|
||||
}
|
||||
|
||||
pub(super) fn schedule_clone_repo(
|
||||
|
|
@ -244,14 +254,12 @@ pub(super) fn schedule_clone_repo(
|
|||
.stdin(Stdio::null())
|
||||
.env("GIT_TERMINAL_PROMPT", "0");
|
||||
|
||||
let askpass_script = match take_pending_git_auth()
|
||||
.map(|auth| {
|
||||
let script = create_askpass_script()?;
|
||||
configure_clone_auth_prompt(&mut cmd, &auth, &script);
|
||||
Ok(script)
|
||||
})
|
||||
.transpose()
|
||||
{
|
||||
let askpass_script = match (|| {
|
||||
let auth = take_pending_git_auth();
|
||||
let script = create_askpass_script()?;
|
||||
configure_clone_auth_prompt(&mut cmd, auth.as_ref(), &script);
|
||||
Ok::<AskPassScript, Error>(script)
|
||||
})() {
|
||||
Ok(script) => script,
|
||||
Err(err) => {
|
||||
send_or_log(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::msg::Msg;
|
||||
use gitcomet_core::domain::{Diff, DiffArea, DiffTarget, LogCursor, LogScope};
|
||||
use gitcomet_core::domain::{DiffArea, DiffTarget, LogCursor, LogScope};
|
||||
use gitcomet_core::error::{Error, ErrorKind};
|
||||
use gitcomet_core::services::decode_utf8_optional;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -631,9 +631,8 @@ pub(super) fn schedule_load_diff(
|
|||
target: DiffTarget,
|
||||
) {
|
||||
spawn_with_repo(executor, repos, repo_id, msg_tx, move |repo, msg_tx| {
|
||||
let result = repo
|
||||
.diff_unified(&target)
|
||||
.map(|text| Diff::from_unified(target.clone(), &text));
|
||||
// UI consumes this parsed diff through paged/lazy row adapters.
|
||||
let result = repo.diff_parsed(&target);
|
||||
send_or_log(
|
||||
&msg_tx,
|
||||
Msg::Internal(crate::msg::InternalMsg::DiffLoaded {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
|||
use gitcomet_ui_gpui::benchmarks::{
|
||||
BranchSidebarFixture, CommitDetailsFixture, ConflictResolvedOutputGutterScrollFixture,
|
||||
ConflictSearchQueryUpdateFixture, ConflictSplitResizeStepFixture,
|
||||
ConflictThreeWayScrollFixture, ConflictTwoWaySplitScrollFixture, HistoryGraphFixture,
|
||||
LargeFileDiffScrollFixture, OpenRepoFixture,
|
||||
ConflictThreeWayScrollFixture, ConflictThreeWayVisibleMapBuildFixture,
|
||||
ConflictTwoWaySplitScrollFixture, FileDiffSyntaxCacheDropFixture, FileDiffSyntaxPrepareFixture,
|
||||
FileDiffSyntaxReparseFixture, HistoryGraphFixture, LargeFileDiffScrollFixture, OpenRepoFixture,
|
||||
PatchDiffPagedRowsFixture, PatchDiffSearchQueryUpdateFixture,
|
||||
ResolvedOutputRecomputeIncrementalFixture, TextInputHighlightDensity,
|
||||
TextInputLongLineCapFixture, TextInputPrepaintWindowedFixture,
|
||||
TextInputRunsStreamedHighlightFixture, TextInputWrapIncrementalBurstEditsFixture,
|
||||
TextInputWrapIncrementalTabsFixture, TextModelBulkLoadLargeFixture,
|
||||
TextModelSnapshotCloneCostFixture, WorktreePreviewRenderFixture,
|
||||
};
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
|
@ -204,6 +211,407 @@ fn bench_large_file_diff_scroll(c: &mut Criterion) {
|
|||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_input_prepaint_windowed(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINES", 20_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINE_BYTES", 128);
|
||||
let window_rows = env_usize("GITCOMET_BENCH_TEXT_INPUT_WINDOW_ROWS", 80);
|
||||
let wrap_width = env_usize("GITCOMET_BENCH_TEXT_INPUT_WRAP_WIDTH_PX", 720);
|
||||
|
||||
let mut windowed_fixture = TextInputPrepaintWindowedFixture::new(lines, line_bytes, wrap_width);
|
||||
let mut full_fixture = TextInputPrepaintWindowedFixture::new(lines, line_bytes, wrap_width);
|
||||
|
||||
let mut group = c.benchmark_group("text_input_prepaint_windowed");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("window_rows", window_rows),
|
||||
&window_rows,
|
||||
|b, &window_rows| {
|
||||
let mut start = 0usize;
|
||||
b.iter(|| {
|
||||
let h = windowed_fixture.run_windowed_step(start, window_rows.max(1));
|
||||
start = start.wrapping_add(window_rows.max(1) / 2 + 1)
|
||||
% windowed_fixture.total_rows().max(1);
|
||||
h
|
||||
})
|
||||
},
|
||||
);
|
||||
group.bench_function(BenchmarkId::from_parameter("full_document_control"), |b| {
|
||||
b.iter(|| full_fixture.run_full_document_step())
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_input_runs_streamed_highlight(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINES", 20_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINE_BYTES", 128);
|
||||
let window_rows = env_usize("GITCOMET_BENCH_TEXT_INPUT_WINDOW_ROWS", 80);
|
||||
|
||||
let dense_fixture = TextInputRunsStreamedHighlightFixture::new(
|
||||
lines,
|
||||
line_bytes,
|
||||
window_rows,
|
||||
TextInputHighlightDensity::Dense,
|
||||
);
|
||||
let sparse_fixture = TextInputRunsStreamedHighlightFixture::new(
|
||||
lines,
|
||||
line_bytes,
|
||||
window_rows,
|
||||
TextInputHighlightDensity::Sparse,
|
||||
);
|
||||
|
||||
let mut dense_group = c.benchmark_group("text_input_runs_streamed_highlight_dense");
|
||||
dense_group.sample_size(10);
|
||||
dense_group.warm_up_time(Duration::from_secs(1));
|
||||
dense_group.bench_function(BenchmarkId::from_parameter("legacy_scan"), |b| {
|
||||
let mut start = 0usize;
|
||||
b.iter(|| {
|
||||
let h = dense_fixture.run_legacy_step(start);
|
||||
start = dense_fixture.next_start_row(start);
|
||||
h
|
||||
})
|
||||
});
|
||||
dense_group.bench_function(BenchmarkId::from_parameter("streamed_cursor"), |b| {
|
||||
let mut start = 0usize;
|
||||
b.iter(|| {
|
||||
let h = dense_fixture.run_streamed_step(start);
|
||||
start = dense_fixture.next_start_row(start);
|
||||
h
|
||||
})
|
||||
});
|
||||
dense_group.finish();
|
||||
|
||||
let mut sparse_group = c.benchmark_group("text_input_runs_streamed_highlight_sparse");
|
||||
sparse_group.sample_size(10);
|
||||
sparse_group.warm_up_time(Duration::from_secs(1));
|
||||
sparse_group.bench_function(BenchmarkId::from_parameter("legacy_scan"), |b| {
|
||||
let mut start = 0usize;
|
||||
b.iter(|| {
|
||||
let h = sparse_fixture.run_legacy_step(start);
|
||||
start = sparse_fixture.next_start_row(start);
|
||||
h
|
||||
})
|
||||
});
|
||||
sparse_group.bench_function(BenchmarkId::from_parameter("streamed_cursor"), |b| {
|
||||
let mut start = 0usize;
|
||||
b.iter(|| {
|
||||
let h = sparse_fixture.run_streamed_step(start);
|
||||
start = sparse_fixture.next_start_row(start);
|
||||
h
|
||||
})
|
||||
});
|
||||
sparse_group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_input_long_line_cap(c: &mut Criterion) {
|
||||
let long_line_bytes = env_usize("GITCOMET_BENCH_TEXT_INPUT_LONG_LINE_BYTES", 256 * 1024);
|
||||
let max_shape_bytes = env_usize("GITCOMET_BENCH_TEXT_INPUT_MAX_LINE_SHAPE_BYTES", 4 * 1024);
|
||||
let fixture = TextInputLongLineCapFixture::new(long_line_bytes);
|
||||
|
||||
let mut group = c.benchmark_group("text_input_long_line_cap");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_function(BenchmarkId::new("capped_bytes", max_shape_bytes), |b| {
|
||||
b.iter(|| fixture.run_with_cap(max_shape_bytes))
|
||||
});
|
||||
group.bench_function(BenchmarkId::from_parameter("uncapped_control"), |b| {
|
||||
b.iter(|| fixture.run_without_cap())
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_input_wrap_incremental_tabs(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINES", 20_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINE_BYTES", 128);
|
||||
let wrap_width = env_usize("GITCOMET_BENCH_TEXT_INPUT_WRAP_WIDTH_PX", 720);
|
||||
let mut full_fixture = TextInputWrapIncrementalTabsFixture::new(lines, line_bytes, wrap_width);
|
||||
let mut incremental_fixture =
|
||||
TextInputWrapIncrementalTabsFixture::new(lines, line_bytes, wrap_width);
|
||||
|
||||
let mut group = c.benchmark_group("text_input_wrap_incremental_tabs");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_function(BenchmarkId::from_parameter("full_recompute"), |b| {
|
||||
let mut edit_ix = 0usize;
|
||||
b.iter(|| {
|
||||
let h = full_fixture.run_full_recompute_step(edit_ix);
|
||||
edit_ix = edit_ix.wrapping_add(17);
|
||||
h
|
||||
})
|
||||
});
|
||||
group.bench_function(BenchmarkId::from_parameter("incremental_patch"), |b| {
|
||||
let mut edit_ix = 0usize;
|
||||
b.iter(|| {
|
||||
let h = incremental_fixture.run_incremental_step(edit_ix);
|
||||
edit_ix = edit_ix.wrapping_add(17);
|
||||
h
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_input_wrap_incremental_burst_edits(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINES", 20_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_TEXT_INPUT_LINE_BYTES", 128);
|
||||
let wrap_width = env_usize("GITCOMET_BENCH_TEXT_INPUT_WRAP_WIDTH_PX", 720);
|
||||
let edits_per_burst = env_usize("GITCOMET_BENCH_TEXT_INPUT_BURST_EDITS", 12);
|
||||
let mut full_fixture =
|
||||
TextInputWrapIncrementalBurstEditsFixture::new(lines, line_bytes, wrap_width);
|
||||
let mut incremental_fixture =
|
||||
TextInputWrapIncrementalBurstEditsFixture::new(lines, line_bytes, wrap_width);
|
||||
|
||||
let mut group = c.benchmark_group("text_input_wrap_incremental_burst_edits");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("full_recompute", edits_per_burst),
|
||||
&edits_per_burst,
|
||||
|b, &edits_per_burst| {
|
||||
b.iter(|| full_fixture.run_full_recompute_burst_step(edits_per_burst))
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("incremental_patch", edits_per_burst),
|
||||
&edits_per_burst,
|
||||
|b, &edits_per_burst| {
|
||||
b.iter(|| incremental_fixture.run_incremental_burst_step(edits_per_burst))
|
||||
},
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_model_snapshot_clone_cost(c: &mut Criterion) {
|
||||
let bytes = env_usize("GITCOMET_BENCH_TEXT_MODEL_BYTES", 2 * 1024 * 1024);
|
||||
let clones = env_usize("GITCOMET_BENCH_TEXT_MODEL_SNAPSHOT_CLONES", 8_192);
|
||||
let fixture = TextModelSnapshotCloneCostFixture::new(bytes);
|
||||
|
||||
let mut group = c.benchmark_group("text_model_snapshot_clone_cost");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("piece_table_snapshot_clone", clones),
|
||||
&clones,
|
||||
|b, &clones| b.iter(|| fixture.run_snapshot_clone_step(clones)),
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("shared_string_clone_control", clones),
|
||||
&clones,
|
||||
|b, &clones| b.iter(|| fixture.run_string_clone_control_step(clones)),
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_model_bulk_load_large(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_TEXT_MODEL_LINES", 20_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_TEXT_MODEL_LINE_BYTES", 128);
|
||||
let fixture = TextModelBulkLoadLargeFixture::new(lines, line_bytes);
|
||||
|
||||
let mut group = c.benchmark_group("text_model_bulk_load_large");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("piece_table_append_large"),
|
||||
|b| b.iter(|| fixture.run_piece_table_bulk_load_step()),
|
||||
);
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("piece_table_from_large_text"),
|
||||
|b| b.iter(|| fixture.run_piece_table_from_large_text_step()),
|
||||
);
|
||||
group.bench_function(BenchmarkId::from_parameter("string_push_control"), |b| {
|
||||
b.iter(|| fixture.run_string_bulk_load_control_step())
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_file_diff_syntax_prepare(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_LINES", 4_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_LINE_BYTES", 128);
|
||||
let fixture = FileDiffSyntaxPrepareFixture::new(lines, line_bytes);
|
||||
fixture.prewarm();
|
||||
|
||||
let mut group = c.benchmark_group("file_diff_syntax_prepare");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
|
||||
let mut cold_nonce = 0u64;
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("file_diff_syntax_prepare_cold"),
|
||||
|b| {
|
||||
b.iter(|| {
|
||||
cold_nonce = cold_nonce.wrapping_add(1);
|
||||
fixture.run_prepare_cold(cold_nonce)
|
||||
})
|
||||
},
|
||||
);
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("file_diff_syntax_prepare_warm"),
|
||||
|b| b.iter(|| fixture.run_prepare_warm()),
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_file_diff_syntax_query_stress(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_STRESS_LINES", 256);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_STRESS_LINE_BYTES", 4_096);
|
||||
let nesting_depth = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_STRESS_NESTING", 128);
|
||||
let fixture = FileDiffSyntaxPrepareFixture::new_query_stress(lines, line_bytes, nesting_depth);
|
||||
|
||||
let mut group = c.benchmark_group("file_diff_syntax_query_stress");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
|
||||
let mut nonce = 0u64;
|
||||
group.bench_function(BenchmarkId::from_parameter("nested_long_lines_cold"), |b| {
|
||||
b.iter(|| {
|
||||
nonce = nonce.wrapping_add(1);
|
||||
fixture.run_prepare_cold(nonce)
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_file_diff_syntax_reparse(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_LINES", 4_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_LINE_BYTES", 128);
|
||||
let mut small_fixture = FileDiffSyntaxReparseFixture::new(lines, line_bytes);
|
||||
let mut large_fixture = FileDiffSyntaxReparseFixture::new(lines, line_bytes);
|
||||
|
||||
let mut group = c.benchmark_group("file_diff_syntax_reparse");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("file_diff_syntax_reparse_small_edit"),
|
||||
|b| b.iter(|| small_fixture.run_small_edit_step()),
|
||||
);
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("file_diff_syntax_reparse_large_edit"),
|
||||
|b| b.iter(|| large_fixture.run_large_edit_step()),
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_file_diff_syntax_cache_drop(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_DROP_LINES", 2_048);
|
||||
let tokens_per_line = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_DROP_TOKENS_PER_LINE", 8);
|
||||
let replacements = env_usize("GITCOMET_BENCH_FILE_DIFF_SYNTAX_DROP_REPLACEMENTS", 4);
|
||||
let fixture = FileDiffSyntaxCacheDropFixture::new(lines, tokens_per_line, replacements);
|
||||
|
||||
let mut group = c.benchmark_group("file_diff_syntax_cache_drop");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("deferred_drop", replacements),
|
||||
&replacements,
|
||||
|b, &_replacements| {
|
||||
b.iter_custom(|iters| {
|
||||
let mut total = Duration::ZERO;
|
||||
let mut seed = 0usize;
|
||||
for _ in 0..iters {
|
||||
let _ = fixture.flush_deferred_drop_queue();
|
||||
total = total.saturating_add(fixture.run_deferred_drop_timed_step(seed));
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
total
|
||||
})
|
||||
},
|
||||
);
|
||||
let _ = fixture.flush_deferred_drop_queue();
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("inline_drop_control", replacements),
|
||||
&replacements,
|
||||
|b, &_replacements| {
|
||||
b.iter_custom(|iters| {
|
||||
let mut total = Duration::ZERO;
|
||||
let mut seed = 0usize;
|
||||
for _ in 0..iters {
|
||||
total = total.saturating_add(fixture.run_inline_drop_control_timed_step(seed));
|
||||
seed = seed.wrapping_add(1);
|
||||
}
|
||||
total
|
||||
})
|
||||
},
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_prepared_syntax_multidoc_cache_hit_rate(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_PREPARED_SYNTAX_LINES", 4_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_PREPARED_SYNTAX_LINE_BYTES", 128);
|
||||
let docs = env_usize("GITCOMET_BENCH_PREPARED_SYNTAX_HOT_DOCS", 6);
|
||||
let fixture = FileDiffSyntaxPrepareFixture::new(lines, line_bytes);
|
||||
|
||||
let mut group = c.benchmark_group("prepared_syntax_multidoc_cache_hit_rate");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
let mut nonce = 0u64;
|
||||
group.bench_with_input(BenchmarkId::new("hot_docs", docs), &docs, |b, &docs| {
|
||||
b.iter(|| {
|
||||
nonce = nonce.wrapping_add(1);
|
||||
fixture.run_prepared_syntax_multidoc_cache_hit_rate_step(docs, nonce)
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_prepared_syntax_chunk_miss_cost(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_PREPARED_SYNTAX_LINES", 4_000);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_PREPARED_SYNTAX_LINE_BYTES", 128);
|
||||
let fixture = FileDiffSyntaxPrepareFixture::new(lines, line_bytes);
|
||||
|
||||
let mut group = c.benchmark_group("prepared_syntax_chunk_miss_cost");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
let mut nonce = 0u64;
|
||||
group.bench_function(BenchmarkId::from_parameter("chunk_miss"), |b| {
|
||||
b.iter_custom(|iters| {
|
||||
let mut total = Duration::ZERO;
|
||||
for _ in 0..iters {
|
||||
nonce = nonce.wrapping_add(1);
|
||||
total =
|
||||
total.saturating_add(fixture.run_prepared_syntax_chunk_miss_cost_step(nonce));
|
||||
}
|
||||
total
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_worktree_preview_render(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_WORKTREE_PREVIEW_LINES", 4_000);
|
||||
let window = env_usize("GITCOMET_BENCH_WORKTREE_PREVIEW_WINDOW", 200);
|
||||
let line_bytes = env_usize("GITCOMET_BENCH_WORKTREE_PREVIEW_LINE_BYTES", 128);
|
||||
let fixture = WorktreePreviewRenderFixture::new(lines, line_bytes);
|
||||
|
||||
let mut group = c.benchmark_group("worktree_preview_render");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("cached_lookup_window", window),
|
||||
&window,
|
||||
|b, &window| {
|
||||
let mut start = 0usize;
|
||||
b.iter(|| {
|
||||
let h = fixture.run_cached_lookup_step(start, window);
|
||||
start = start.wrapping_add(window) % lines.max(1);
|
||||
h
|
||||
})
|
||||
},
|
||||
);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("render_time_prepare_window", window),
|
||||
&window,
|
||||
|b, &window| {
|
||||
let mut start = 0usize;
|
||||
b.iter(|| {
|
||||
let h = fixture.run_render_time_prepare_step(start, window);
|
||||
start = start.wrapping_add(window) % lines.max(1);
|
||||
h
|
||||
})
|
||||
},
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_conflict_three_way_scroll(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_CONFLICT_LINES", 10_000);
|
||||
let conflict_blocks = env_usize("GITCOMET_BENCH_CONFLICT_BLOCKS", 300);
|
||||
|
|
@ -228,6 +636,23 @@ fn bench_conflict_three_way_scroll(c: &mut Criterion) {
|
|||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_conflict_three_way_visible_map_build(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_CONFLICT_LINES", 10_000);
|
||||
let conflict_blocks = env_usize("GITCOMET_BENCH_CONFLICT_BLOCKS", 300);
|
||||
let fixture = ConflictThreeWayVisibleMapBuildFixture::new(lines, conflict_blocks);
|
||||
|
||||
let mut group = c.benchmark_group("conflict_three_way_visible_map_build");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_function(BenchmarkId::from_parameter("linear_two_pointer"), |b| {
|
||||
b.iter(|| fixture.run_linear_step())
|
||||
});
|
||||
group.bench_function(BenchmarkId::from_parameter("legacy_find_scan"), |b| {
|
||||
b.iter(|| fixture.run_legacy_step())
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_conflict_two_way_split_scroll(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_CONFLICT_LINES", 10_000);
|
||||
let conflict_blocks = env_usize("GITCOMET_BENCH_CONFLICT_BLOCKS", 300);
|
||||
|
|
@ -306,6 +731,62 @@ fn bench_conflict_search_query_update(c: &mut Criterion) {
|
|||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_patch_diff_search_query_update(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_PATCH_DIFF_LINES", 10_000);
|
||||
let window = env_usize("GITCOMET_BENCH_PATCH_DIFF_WINDOW", 200);
|
||||
let mut fixture = PatchDiffSearchQueryUpdateFixture::new(lines);
|
||||
let query_cycle = [
|
||||
"s", "sh", "sha", "shar", "share", "shared", "shared_", "shared_1",
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("patch_diff_search_query_update");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::from_parameter(format!("window_{window}")),
|
||||
&window,
|
||||
|b, &window| {
|
||||
let mut start = 0usize;
|
||||
let mut query_ix = 0usize;
|
||||
b.iter(|| {
|
||||
let query = query_cycle[query_ix % query_cycle.len()];
|
||||
let h = fixture.run_query_update_step(query, start, window);
|
||||
query_ix = query_ix.wrapping_add(1);
|
||||
start = start.wrapping_add(window.max(1) / 2 + 1) % fixture.visible_rows().max(1);
|
||||
h
|
||||
})
|
||||
},
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_patch_diff_paged_rows(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_PATCH_DIFF_LINES", 20_000);
|
||||
let window = env_usize("GITCOMET_BENCH_PATCH_DIFF_WINDOW", 200);
|
||||
let fixture = PatchDiffPagedRowsFixture::new(lines);
|
||||
|
||||
let mut group = c.benchmark_group("patch_diff_paged_rows");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_function(BenchmarkId::from_parameter("eager_full_materialize"), |b| {
|
||||
b.iter(|| fixture.run_eager_full_materialize_step())
|
||||
});
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("paged_first_window", window),
|
||||
&window,
|
||||
|b, &window| b.iter(|| fixture.run_paged_first_window_step(window)),
|
||||
);
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("inline_visible_eager_scan"),
|
||||
|b| b.iter(|| fixture.run_inline_visible_eager_scan_step()),
|
||||
);
|
||||
group.bench_function(
|
||||
BenchmarkId::from_parameter("inline_visible_hidden_map"),
|
||||
|b| b.iter(|| fixture.run_inline_visible_hidden_map_step()),
|
||||
);
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_conflict_split_resize_step(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_CONFLICT_LINES", 10_000);
|
||||
let conflict_blocks = env_usize("GITCOMET_BENCH_CONFLICT_BLOCKS", 300);
|
||||
|
|
@ -328,6 +809,25 @@ fn bench_conflict_split_resize_step(c: &mut Criterion) {
|
|||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_resolved_output_recompute_incremental(c: &mut Criterion) {
|
||||
let lines = env_usize("GITCOMET_BENCH_CONFLICT_LINES", 10_000);
|
||||
let conflict_blocks = env_usize("GITCOMET_BENCH_CONFLICT_BLOCKS", 300);
|
||||
let mut full_fixture = ResolvedOutputRecomputeIncrementalFixture::new(lines, conflict_blocks);
|
||||
let mut incremental_fixture =
|
||||
ResolvedOutputRecomputeIncrementalFixture::new(lines, conflict_blocks);
|
||||
|
||||
let mut group = c.benchmark_group("resolved_output_recompute_incremental");
|
||||
group.sample_size(10);
|
||||
group.warm_up_time(Duration::from_secs(1));
|
||||
group.bench_function(BenchmarkId::from_parameter("full_recompute"), |b| {
|
||||
b.iter(|| full_fixture.run_full_recompute_step())
|
||||
});
|
||||
group.bench_function(BenchmarkId::from_parameter("incremental_recompute"), |b| {
|
||||
b.iter(|| incremental_fixture.run_incremental_recompute_step())
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_open_repo,
|
||||
|
|
@ -335,10 +835,28 @@ criterion_group!(
|
|||
bench_history_graph,
|
||||
bench_commit_details,
|
||||
bench_large_file_diff_scroll,
|
||||
bench_text_input_prepaint_windowed,
|
||||
bench_text_input_runs_streamed_highlight,
|
||||
bench_text_input_long_line_cap,
|
||||
bench_text_input_wrap_incremental_tabs,
|
||||
bench_text_input_wrap_incremental_burst_edits,
|
||||
bench_text_model_snapshot_clone_cost,
|
||||
bench_text_model_bulk_load_large,
|
||||
bench_file_diff_syntax_prepare,
|
||||
bench_file_diff_syntax_query_stress,
|
||||
bench_file_diff_syntax_reparse,
|
||||
bench_file_diff_syntax_cache_drop,
|
||||
bench_prepared_syntax_multidoc_cache_hit_rate,
|
||||
bench_prepared_syntax_chunk_miss_cost,
|
||||
bench_worktree_preview_render,
|
||||
bench_conflict_three_way_scroll,
|
||||
bench_conflict_three_way_visible_map_build,
|
||||
bench_conflict_two_way_split_scroll,
|
||||
bench_conflict_resolved_output_gutter_scroll,
|
||||
bench_conflict_search_query_update,
|
||||
bench_conflict_split_resize_step
|
||||
bench_patch_diff_search_query_update,
|
||||
bench_patch_diff_paged_rows,
|
||||
bench_conflict_split_resize_step,
|
||||
bench_resolved_output_recompute_incremental
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
mod scrollbar;
|
||||
mod text_input;
|
||||
pub(crate) mod text_model;
|
||||
|
||||
pub use scrollbar::{Scrollbar, ScrollbarMarker, ScrollbarMarkerKind};
|
||||
pub use text_input::{
|
||||
|
|
@ -8,6 +9,10 @@ pub use text_input::{
|
|||
SelectPageDown, SelectPageUp, SelectRight, SelectUp, SelectWordLeft, SelectWordRight,
|
||||
TextInput, TextInputOptions, Undo, Up, WordLeft, WordRight,
|
||||
};
|
||||
pub(crate) use text_input::{
|
||||
benchmark_text_input_runs_legacy_visible_window,
|
||||
benchmark_text_input_runs_streamed_visible_window,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use text_input::ShowCharacterPalette;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
854
crates/gitcomet-ui-gpui/src/kit/text_model.rs
Normal file
854
crates/gitcomet-ui-gpui/src/kit/text_model.rs
Normal file
|
|
@ -0,0 +1,854 @@
|
|||
use gpui::SharedString;
|
||||
use std::ops::{Deref, Range};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
const LARGE_TEXT_CHUNK_BYTES: usize = 16 * 1024;
|
||||
const LARGE_TEXT_PARALLEL_THRESHOLD: usize = 512 * 1024;
|
||||
const LARGE_TEXT_PARALLEL_MIN_CHUNKS: usize = 8;
|
||||
const LARGE_TEXT_MAX_THREADS: usize = 8;
|
||||
|
||||
static NEXT_MODEL_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum BufferId {
|
||||
Original,
|
||||
Add,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct Piece {
|
||||
buffer: BufferId,
|
||||
chunk_index: usize,
|
||||
start: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
fn prefix(&self, len: usize) -> Option<Self> {
|
||||
(len > 0).then_some(Self {
|
||||
buffer: self.buffer,
|
||||
chunk_index: self.chunk_index,
|
||||
start: self.start,
|
||||
len,
|
||||
})
|
||||
}
|
||||
|
||||
fn suffix(&self, offset: usize) -> Option<Self> {
|
||||
let suffix_len = self.len.saturating_sub(offset);
|
||||
(suffix_len > 0).then_some(Self {
|
||||
buffer: self.buffer,
|
||||
chunk_index: self.chunk_index,
|
||||
start: self.start.saturating_add(offset),
|
||||
len: suffix_len,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct LineIndex {
|
||||
starts: Vec<usize>,
|
||||
}
|
||||
|
||||
impl LineIndex {
|
||||
fn from_text(text: &str) -> Self {
|
||||
let mut starts = Vec::with_capacity(text.bytes().filter(|&b| b == b'\n').count() + 1);
|
||||
starts.push(0);
|
||||
for (ix, byte) in text.bytes().enumerate() {
|
||||
if byte == b'\n' {
|
||||
starts.push(ix + 1);
|
||||
}
|
||||
}
|
||||
Self { starts }
|
||||
}
|
||||
|
||||
fn starts(&self) -> &[usize] {
|
||||
self.starts.as_slice()
|
||||
}
|
||||
|
||||
fn apply_edit(&mut self, range: Range<usize>, inserted: &str) {
|
||||
debug_assert_eq!(self.starts.first().copied(), Some(0));
|
||||
debug_assert!(
|
||||
self.starts.windows(2).all(|window| window[0] < window[1]),
|
||||
"line starts must remain strictly increasing before edit"
|
||||
);
|
||||
|
||||
let old_len = range.end.saturating_sub(range.start);
|
||||
let new_len = inserted.len();
|
||||
let delta = new_len as isize - old_len as isize;
|
||||
|
||||
let prefix_len = self.starts.partition_point(|&start| start <= range.start);
|
||||
// For non-empty edits, a line start at `range.end` is produced by a
|
||||
// newline byte inside the replaced range and must be removed.
|
||||
let suffix_start = self.starts.partition_point(|&start| start <= range.end);
|
||||
|
||||
let inserted_breaks = inserted.bytes().filter(|&b| b == b'\n').count();
|
||||
let mut updated = Vec::with_capacity(
|
||||
prefix_len
|
||||
.saturating_add(inserted_breaks)
|
||||
.saturating_add(self.starts.len().saturating_sub(suffix_start))
|
||||
.saturating_add(1),
|
||||
);
|
||||
updated.extend_from_slice(&self.starts[..prefix_len]);
|
||||
|
||||
for (ix, byte) in inserted.bytes().enumerate() {
|
||||
if byte == b'\n' {
|
||||
updated.push(range.start.saturating_add(ix).saturating_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
for &start in &self.starts[suffix_start..] {
|
||||
let shifted = if delta >= 0 {
|
||||
start.saturating_add(delta as usize)
|
||||
} else {
|
||||
start.saturating_sub((-delta) as usize)
|
||||
};
|
||||
updated.push(shifted);
|
||||
}
|
||||
|
||||
updated.sort_unstable();
|
||||
updated.dedup();
|
||||
if updated.first().copied() != Some(0) {
|
||||
updated.insert(0, 0);
|
||||
}
|
||||
self.starts = updated;
|
||||
debug_assert_eq!(self.starts.first().copied(), Some(0));
|
||||
debug_assert!(
|
||||
self.starts.windows(2).all(|window| window[0] < window[1]),
|
||||
"line starts must remain strictly increasing after edit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TextModelCore {
|
||||
model_id: u64,
|
||||
revision: u64,
|
||||
original_chunks: Arc<Vec<Arc<str>>>,
|
||||
add_chunks: Arc<Vec<Arc<str>>>,
|
||||
pieces: Vec<Piece>,
|
||||
len: usize,
|
||||
line_index: LineIndex,
|
||||
materialized: OnceLock<SharedString>,
|
||||
}
|
||||
|
||||
impl Clone for TextModelCore {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
model_id: self.model_id,
|
||||
revision: self.revision,
|
||||
original_chunks: Arc::clone(&self.original_chunks),
|
||||
add_chunks: Arc::clone(&self.add_chunks),
|
||||
pieces: self.pieces.clone(),
|
||||
len: self.len,
|
||||
line_index: self.line_index.clone(),
|
||||
// Do not clone materialized text into writable COW clones.
|
||||
materialized: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextModelCore {
|
||||
fn chunk_for_piece(&self, piece: &Piece) -> &str {
|
||||
match piece.buffer {
|
||||
BufferId::Original => self
|
||||
.original_chunks
|
||||
.get(piece.chunk_index)
|
||||
.map(|chunk| chunk.as_ref())
|
||||
.unwrap_or(""),
|
||||
BufferId::Add => self
|
||||
.add_chunks
|
||||
.get(piece.chunk_index)
|
||||
.map(|chunk| chunk.as_ref())
|
||||
.unwrap_or(""),
|
||||
}
|
||||
}
|
||||
|
||||
fn piece_slice<'a>(&'a self, piece: &Piece) -> &'a str {
|
||||
let chunk = self.chunk_for_piece(piece);
|
||||
let start = piece.start.min(chunk.len());
|
||||
let end = piece.start.saturating_add(piece.len).min(chunk.len());
|
||||
chunk.get(start..end).unwrap_or("")
|
||||
}
|
||||
|
||||
fn materialized(&self) -> &SharedString {
|
||||
self.materialized.get_or_init(|| {
|
||||
if self.pieces.is_empty() {
|
||||
return SharedString::default();
|
||||
}
|
||||
|
||||
let mut text = String::with_capacity(self.len);
|
||||
for piece in &self.pieces {
|
||||
text.push_str(self.piece_slice(piece));
|
||||
}
|
||||
text.into()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextModel {
|
||||
core: Arc<TextModelCore>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextModelSnapshot {
|
||||
core: Arc<TextModelCore>,
|
||||
}
|
||||
|
||||
impl Default for TextModel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextModel {
|
||||
pub fn new() -> Self {
|
||||
Self::from_large_text("")
|
||||
}
|
||||
|
||||
pub fn from_large_text(text: &str) -> Self {
|
||||
let ranges = chunk_ranges(text, LARGE_TEXT_CHUNK_BYTES);
|
||||
let original_chunks = prepare_chunks(
|
||||
text,
|
||||
ranges.as_slice(),
|
||||
LARGE_TEXT_PARALLEL_THRESHOLD,
|
||||
LARGE_TEXT_PARALLEL_MIN_CHUNKS,
|
||||
);
|
||||
let pieces = original_chunks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(chunk_index, chunk)| {
|
||||
(!chunk.is_empty()).then_some(Piece {
|
||||
buffer: BufferId::Original,
|
||||
chunk_index,
|
||||
start: 0,
|
||||
len: chunk.len(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let model_id = NEXT_MODEL_ID.fetch_add(1, Ordering::Relaxed).max(1);
|
||||
Self {
|
||||
core: Arc::new(TextModelCore {
|
||||
model_id,
|
||||
revision: 1,
|
||||
original_chunks: Arc::new(original_chunks),
|
||||
add_chunks: Arc::new(Vec::new()),
|
||||
len: text.len(),
|
||||
line_index: LineIndex::from_text(text),
|
||||
pieces,
|
||||
materialized: OnceLock::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.core.len
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn model_id(&self) -> u64 {
|
||||
self.core.model_id
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.core.revision
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.core.materialized().as_ref()
|
||||
}
|
||||
|
||||
pub fn as_shared_string(&self) -> SharedString {
|
||||
self.core.materialized().clone()
|
||||
}
|
||||
|
||||
pub fn line_starts(&self) -> &[usize] {
|
||||
self.core.line_index.starts()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> TextModelSnapshot {
|
||||
TextModelSnapshot {
|
||||
core: Arc::clone(&self.core),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, text: &str) {
|
||||
*self = Self::from_large_text(text);
|
||||
}
|
||||
|
||||
pub fn append_large(&mut self, text: &str) -> Range<usize> {
|
||||
let start = self.len();
|
||||
self.replace_range(start..start, text)
|
||||
}
|
||||
|
||||
pub fn is_char_boundary(&self, offset: usize) -> bool {
|
||||
self.snapshot().is_char_boundary(offset)
|
||||
}
|
||||
|
||||
pub fn clamp_to_char_boundary(&self, mut offset: usize) -> usize {
|
||||
offset = offset.min(self.len());
|
||||
while offset > 0 && !self.is_char_boundary(offset) {
|
||||
offset = offset.saturating_sub(1);
|
||||
}
|
||||
offset
|
||||
}
|
||||
|
||||
pub fn replace_range(&mut self, range: Range<usize>, new_text: &str) -> Range<usize> {
|
||||
let start = self.clamp_to_char_boundary(range.start.min(self.len()));
|
||||
let end = self.clamp_to_char_boundary(range.end.min(self.len()));
|
||||
let range = if end < start { end..start } else { start..end };
|
||||
if range.is_empty() && new_text.is_empty() {
|
||||
return range.start..range.start;
|
||||
}
|
||||
|
||||
let core = Arc::make_mut(&mut self.core);
|
||||
let (mut left, right_from_start) = split_pieces_at(core.pieces.as_slice(), range.start);
|
||||
let (_removed, mut right) = split_pieces_at(
|
||||
right_from_start.as_slice(),
|
||||
range.end.saturating_sub(range.start),
|
||||
);
|
||||
|
||||
let inserted_pieces = append_add_pieces(core, new_text);
|
||||
left.extend(inserted_pieces);
|
||||
left.append(&mut right);
|
||||
merge_adjacent_pieces(&mut left);
|
||||
|
||||
core.pieces = left;
|
||||
core.len = core
|
||||
.len
|
||||
.saturating_sub(range.end.saturating_sub(range.start))
|
||||
.saturating_add(new_text.len());
|
||||
core.line_index.apply_edit(range.clone(), new_text);
|
||||
core.revision = core.revision.wrapping_add(1).max(1);
|
||||
core.materialized = OnceLock::new();
|
||||
|
||||
range.start..range.start.saturating_add(new_text.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TextModel {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TextModel {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for TextModel {
|
||||
fn from(value: &str) -> Self {
|
||||
Self::from_large_text(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for TextModel {
|
||||
fn from(value: String) -> Self {
|
||||
Self::from_large_text(value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TextModelSnapshot> for TextModel {
|
||||
fn from(snapshot: TextModelSnapshot) -> Self {
|
||||
Self {
|
||||
core: snapshot.core,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextModelSnapshot {
|
||||
pub fn len(&self) -> usize {
|
||||
self.core.len
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn model_id(&self) -> u64 {
|
||||
self.core.model_id
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.core.revision
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.core.materialized().as_ref()
|
||||
}
|
||||
|
||||
pub fn as_shared_string(&self) -> SharedString {
|
||||
self.core.materialized().clone()
|
||||
}
|
||||
|
||||
pub fn line_starts(&self) -> &[usize] {
|
||||
self.core.line_index.starts()
|
||||
}
|
||||
|
||||
pub fn is_char_boundary(&self, offset: usize) -> bool {
|
||||
if offset == 0 || offset == self.core.len {
|
||||
return true;
|
||||
}
|
||||
if offset > self.core.len {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut cursor = 0usize;
|
||||
for piece in &self.core.pieces {
|
||||
let next = cursor.saturating_add(piece.len);
|
||||
if offset == cursor || offset == next {
|
||||
return true;
|
||||
}
|
||||
if offset < next {
|
||||
let local = offset.saturating_sub(cursor);
|
||||
let chunk = self.core.chunk_for_piece(piece);
|
||||
let absolute = piece.start.saturating_add(local);
|
||||
return chunk.is_char_boundary(absolute);
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn clamp_to_char_boundary(&self, mut offset: usize) -> usize {
|
||||
offset = offset.min(self.len());
|
||||
while offset > 0 && !self.is_char_boundary(offset) {
|
||||
offset = offset.saturating_sub(1);
|
||||
}
|
||||
offset
|
||||
}
|
||||
|
||||
pub fn slice_to_string(&self, range: Range<usize>) -> String {
|
||||
let start = self.clamp_to_char_boundary(range.start.min(self.len()));
|
||||
let end = self.clamp_to_char_boundary(range.end.min(self.len()));
|
||||
let range = if end < start { end..start } else { start..end };
|
||||
if range.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(range.end.saturating_sub(range.start));
|
||||
let mut cursor = 0usize;
|
||||
for piece in &self.core.pieces {
|
||||
let piece_start = cursor;
|
||||
let piece_end = cursor.saturating_add(piece.len);
|
||||
if piece_end <= range.start {
|
||||
cursor = piece_end;
|
||||
continue;
|
||||
}
|
||||
if piece_start >= range.end {
|
||||
break;
|
||||
}
|
||||
|
||||
let local_start = range.start.saturating_sub(piece_start);
|
||||
let local_end = range.end.min(piece_end).saturating_sub(piece_start);
|
||||
if local_start < local_end {
|
||||
let chunk = self.core.chunk_for_piece(piece);
|
||||
let chunk_start = piece.start.saturating_add(local_start);
|
||||
let chunk_end = piece.start.saturating_add(local_end);
|
||||
if let Some(slice) = chunk.get(chunk_start..chunk_end) {
|
||||
out.push_str(slice);
|
||||
}
|
||||
}
|
||||
cursor = piece_end;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TextModelSnapshot {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TextModelSnapshot {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for TextModelSnapshot {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.model_id() == other.model_id() && self.revision() == other.revision()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for TextModelSnapshot {}
|
||||
|
||||
fn chunk_ranges(text: &str, chunk_bytes: usize) -> Vec<Range<usize>> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let chunk_bytes = chunk_bytes.max(1);
|
||||
let mut ranges = Vec::with_capacity(text.len() / chunk_bytes + 1);
|
||||
let mut start = 0usize;
|
||||
while start < text.len() {
|
||||
let mut end = (start + chunk_bytes).min(text.len());
|
||||
while end > start && !text.is_char_boundary(end) {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
if end == start {
|
||||
end = text.len();
|
||||
}
|
||||
ranges.push(start..end);
|
||||
start = end;
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
fn prepare_chunks(
|
||||
text: &str,
|
||||
ranges: &[Range<usize>],
|
||||
parallel_threshold: usize,
|
||||
parallel_min_chunks: usize,
|
||||
) -> Vec<Arc<str>> {
|
||||
if ranges.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let should_parallelize = text.len() >= parallel_threshold
|
||||
&& ranges.len() >= parallel_min_chunks
|
||||
&& std::thread::available_parallelism()
|
||||
.map(|n| n.get() > 1)
|
||||
.unwrap_or(false);
|
||||
if !should_parallelize {
|
||||
return ranges
|
||||
.iter()
|
||||
.map(|range| Arc::<str>::from(&text[range.clone()]))
|
||||
.collect();
|
||||
}
|
||||
|
||||
prepare_chunks_parallel(text, ranges)
|
||||
}
|
||||
|
||||
fn prepare_chunks_parallel(text: &str, ranges: &[Range<usize>]) -> Vec<Arc<str>> {
|
||||
let thread_count = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, LARGE_TEXT_MAX_THREADS)
|
||||
.min(ranges.len());
|
||||
if thread_count <= 1 {
|
||||
return ranges
|
||||
.iter()
|
||||
.map(|range| Arc::<str>::from(&text[range.clone()]))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut assignments = vec![Vec::<(usize, Range<usize>)>::new(); thread_count];
|
||||
for (ix, range) in ranges.iter().cloned().enumerate() {
|
||||
assignments[ix % thread_count].push((ix, range));
|
||||
}
|
||||
|
||||
let mut worker_results = vec![Vec::<(usize, Arc<str>)>::new(); thread_count];
|
||||
std::thread::scope(|scope| {
|
||||
for (result_slot, worker_ranges) in worker_results.iter_mut().zip(assignments.into_iter()) {
|
||||
let text_ref = text;
|
||||
scope.spawn(move || {
|
||||
result_slot.reserve(worker_ranges.len());
|
||||
for (ix, range) in worker_ranges {
|
||||
result_slot.push((ix, Arc::<str>::from(&text_ref[range])));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let mut chunks = std::iter::repeat_with(|| Arc::<str>::from(""))
|
||||
.take(ranges.len())
|
||||
.collect::<Vec<_>>();
|
||||
for worker in worker_results {
|
||||
for (ix, chunk) in worker {
|
||||
if let Some(slot) = chunks.get_mut(ix) {
|
||||
*slot = chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
fn split_pieces_at(pieces: &[Piece], offset: usize) -> (Vec<Piece>, Vec<Piece>) {
|
||||
if pieces.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
if offset == 0 {
|
||||
return (Vec::new(), pieces.to_vec());
|
||||
}
|
||||
|
||||
let mut left = Vec::with_capacity(pieces.len());
|
||||
let mut right = Vec::with_capacity(pieces.len());
|
||||
let mut consumed = 0usize;
|
||||
|
||||
for piece in pieces {
|
||||
let piece_end = consumed.saturating_add(piece.len);
|
||||
if piece_end <= offset {
|
||||
left.push(piece.clone());
|
||||
} else if consumed >= offset {
|
||||
right.push(piece.clone());
|
||||
} else {
|
||||
let split_at = offset.saturating_sub(consumed).min(piece.len);
|
||||
if let Some(prefix) = piece.prefix(split_at) {
|
||||
left.push(prefix);
|
||||
}
|
||||
if let Some(suffix) = piece.suffix(split_at) {
|
||||
right.push(suffix);
|
||||
}
|
||||
}
|
||||
consumed = piece_end;
|
||||
}
|
||||
|
||||
(left, right)
|
||||
}
|
||||
|
||||
fn append_add_pieces(core: &mut TextModelCore, text: &str) -> Vec<Piece> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let ranges = chunk_ranges(text, LARGE_TEXT_CHUNK_BYTES);
|
||||
let chunks = prepare_chunks(
|
||||
text,
|
||||
ranges.as_slice(),
|
||||
LARGE_TEXT_PARALLEL_THRESHOLD,
|
||||
LARGE_TEXT_PARALLEL_MIN_CHUNKS,
|
||||
);
|
||||
|
||||
let add_chunks = Arc::make_mut(&mut core.add_chunks);
|
||||
add_chunks.reserve(chunks.len());
|
||||
let base = add_chunks.len();
|
||||
|
||||
let mut pieces = Vec::with_capacity(chunks.len());
|
||||
for (ix, chunk) in chunks.into_iter().enumerate() {
|
||||
let len = chunk.len();
|
||||
add_chunks.push(chunk);
|
||||
pieces.push(Piece {
|
||||
buffer: BufferId::Add,
|
||||
chunk_index: base + ix,
|
||||
start: 0,
|
||||
len,
|
||||
});
|
||||
}
|
||||
pieces
|
||||
}
|
||||
|
||||
fn merge_adjacent_pieces(pieces: &mut Vec<Piece>) {
|
||||
if pieces.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut merged = Vec::with_capacity(pieces.len());
|
||||
let mut current = pieces[0].clone();
|
||||
for piece in pieces.iter().skip(1) {
|
||||
let contiguous = current.buffer == piece.buffer
|
||||
&& current.chunk_index == piece.chunk_index
|
||||
&& current.start.saturating_add(current.len) == piece.start;
|
||||
if contiguous {
|
||||
current.len = current.len.saturating_add(piece.len);
|
||||
continue;
|
||||
}
|
||||
merged.push(current);
|
||||
current = piece.clone();
|
||||
}
|
||||
merged.push(current);
|
||||
*pieces = merged;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn line_starts_for_text(text: &str) -> Vec<usize> {
|
||||
let mut starts = vec![0];
|
||||
for (ix, byte) in text.bytes().enumerate() {
|
||||
if byte == b'\n' {
|
||||
starts.push(ix + 1);
|
||||
}
|
||||
}
|
||||
starts
|
||||
}
|
||||
|
||||
fn clamp_to_char_boundary(text: &str, mut offset: usize) -> usize {
|
||||
offset = offset.min(text.len());
|
||||
while offset > 0 && !text.is_char_boundary(offset) {
|
||||
offset = offset.saturating_sub(1);
|
||||
}
|
||||
offset
|
||||
}
|
||||
|
||||
fn normalize_range(text: &str, range: Range<usize>) -> Range<usize> {
|
||||
let start = clamp_to_char_boundary(text, range.start.min(text.len()));
|
||||
let end = clamp_to_char_boundary(text, range.end.min(text.len()));
|
||||
if end < start { end..start } else { start..end }
|
||||
}
|
||||
|
||||
fn replace_control(text: &mut String, range: Range<usize>, inserted: &str) -> Range<usize> {
|
||||
let normalized = normalize_range(text.as_str(), range);
|
||||
text.replace_range(normalized.clone(), inserted);
|
||||
normalized.start..normalized.start.saturating_add(inserted.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_range_updates_text_and_line_index() {
|
||||
let mut model = TextModel::from_large_text("alpha\nbeta\ngamma");
|
||||
let inserted = model.replace_range(6..10, "BETA\nDELTA");
|
||||
assert_eq!(inserted, 6..16);
|
||||
assert_eq!(model.as_str(), "alpha\nBETA\nDELTA\ngamma");
|
||||
assert_eq!(model.line_starts(), &[0, 6, 11, 17]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_range_keeps_line_start_when_edit_ends_at_line_boundary() {
|
||||
let mut model = TextModel::from_large_text("ab\ncd");
|
||||
let inserted = model.replace_range(0..3, "");
|
||||
assert_eq!(inserted, 0..0);
|
||||
assert_eq!(model.as_str(), "cd");
|
||||
assert_eq!(model.line_starts(), &[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_range_dropping_newline_removes_stale_line_start() {
|
||||
let mut model = TextModel::from_large_text("a\nb\nc");
|
||||
let inserted = model.replace_range(1..2, "");
|
||||
assert_eq!(inserted, 1..1);
|
||||
assert_eq!(model.as_str(), "ab\nc");
|
||||
assert_eq!(model.line_starts(), &[0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_clone_is_cheap_and_immutable_after_mutation() {
|
||||
let mut model = TextModel::from_large_text("hello world");
|
||||
let snapshot_a = model.snapshot();
|
||||
let snapshot_b = snapshot_a.clone();
|
||||
let snapshot_revision = snapshot_a.revision();
|
||||
|
||||
model.replace_range(0..5, "goodbye");
|
||||
|
||||
assert_eq!(snapshot_a.as_str(), "hello world");
|
||||
assert_eq!(snapshot_b.as_str(), "hello world");
|
||||
assert_eq!(snapshot_a.revision(), snapshot_revision);
|
||||
assert_ne!(snapshot_a.revision(), model.revision());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_large_uses_piece_table_insert_path() {
|
||||
let mut model = TextModel::new();
|
||||
let inserted = model.append_large("first\n");
|
||||
assert_eq!(inserted, 0..6);
|
||||
let inserted = model.append_large("second");
|
||||
assert_eq!(inserted, 6..12);
|
||||
assert_eq!(model.as_str(), "first\nsecond");
|
||||
assert_eq!(model.line_starts(), &[0, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_large_text_chunks_preserve_content() {
|
||||
let mut text = String::new();
|
||||
for ix in 0..2_048usize {
|
||||
text.push_str(format!("line_{ix:04}\n").as_str());
|
||||
}
|
||||
let model = TextModel::from_large_text(text.as_str());
|
||||
assert_eq!(model.len(), text.len());
|
||||
assert_eq!(model.as_str(), text);
|
||||
assert_eq!(model.line_starts().len(), 2_049);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_range_clamps_unicode_boundaries() {
|
||||
let mut model = TextModel::from_large_text("🙂\nβeta");
|
||||
let inserted = model.replace_range(1..6, "é\n");
|
||||
assert_eq!(inserted, 0..3);
|
||||
assert_eq!(model.as_str(), "é\nβeta");
|
||||
assert_eq!(model.line_starts(), &[0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_slice_to_string_matches_full_text_across_piece_boundaries() {
|
||||
let mut model = TextModel::new();
|
||||
let _ = model.append_large("left-");
|
||||
let _ = model.append_large("🙂middle-");
|
||||
let _ = model.append_large("right");
|
||||
let snapshot = model.snapshot();
|
||||
let full = snapshot.as_str();
|
||||
let expected_range = normalize_range(full, 3..17);
|
||||
let expected = full[expected_range].to_string();
|
||||
assert_eq!(snapshot.slice_to_string(3..17), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_range_normalizes_reversed_and_out_of_bounds_ranges() {
|
||||
let mut model = TextModel::from_large_text("abcdef");
|
||||
let inserted = model.replace_range(128..2, "XY");
|
||||
assert_eq!(inserted, 2..4);
|
||||
assert_eq!(model.as_str(), "abXY");
|
||||
assert_eq!(model.line_starts(), &[0]);
|
||||
|
||||
let inserted = model.replace_range(4..999, "!");
|
||||
assert_eq!(inserted, 4..5);
|
||||
assert_eq!(model.as_str(), "abXY!");
|
||||
assert_eq!(model.line_starts(), &[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_range_handles_empty_model_insert_and_delete() {
|
||||
let mut model = TextModel::new();
|
||||
let inserted = model.replace_range(0..16, "");
|
||||
assert_eq!(inserted, 0..0);
|
||||
assert_eq!(model.as_str(), "");
|
||||
assert_eq!(model.line_starts(), &[0]);
|
||||
|
||||
let inserted = model.replace_range(0..0, "hello\n");
|
||||
assert_eq!(inserted, 0..6);
|
||||
assert_eq!(model.as_str(), "hello\n");
|
||||
assert_eq!(model.line_starts(), &[0, 6]);
|
||||
|
||||
let inserted = model.replace_range(0..usize::MAX, "");
|
||||
assert_eq!(inserted, 0..0);
|
||||
assert_eq!(model.as_str(), "");
|
||||
assert_eq!(model.line_starts(), &[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_range_updates_consecutive_newline_line_starts() {
|
||||
let mut model = TextModel::from_large_text("a\n\n\nb");
|
||||
let inserted = model.replace_range(1..4, "\n\n");
|
||||
assert_eq!(inserted, 1..3);
|
||||
assert_eq!(model.as_str(), "a\n\nb");
|
||||
assert_eq!(model.line_starts(), &[0, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_edits_match_string_control() {
|
||||
let mut model = TextModel::from_large_text("😀alpha\nβeta\n\ngamma");
|
||||
let mut control = model.as_str().to_string();
|
||||
let edits = [
|
||||
(1usize, 6usize, "X"),
|
||||
(12usize, 4usize, "Q\n"),
|
||||
(999usize, 999usize, "\ntail"),
|
||||
(3usize, 1_000usize, ""),
|
||||
(0usize, 0usize, "prefix\n"),
|
||||
(2usize, 2usize, "🙂"),
|
||||
(5usize, 8usize, ""),
|
||||
(usize::MAX - 1, 1usize, "Ω"),
|
||||
];
|
||||
|
||||
for (start, end, inserted_text) in edits {
|
||||
let range = start..end;
|
||||
let expected_inserted = replace_control(&mut control, range.clone(), inserted_text);
|
||||
let actual_inserted = model.replace_range(range, inserted_text);
|
||||
assert_eq!(actual_inserted, expected_inserted);
|
||||
assert_eq!(model.as_str(), control);
|
||||
let expected_starts = line_starts_for_text(control.as_str());
|
||||
assert_eq!(model.line_starts(), expected_starts.as_slice());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -990,6 +990,34 @@ fn text_line_count_usize(text: &str) -> usize {
|
|||
}
|
||||
}
|
||||
|
||||
fn indexed_line_count(text: &str, line_starts: &[usize]) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
line_starts.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_line_text<'a>(text: &'a str, line_starts: &[usize], line_ix: usize) -> Option<&'a str> {
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let text_len = text.len();
|
||||
let start = line_starts.get(line_ix).copied().unwrap_or(text_len);
|
||||
if start >= text_len {
|
||||
return None;
|
||||
}
|
||||
let mut end = line_starts
|
||||
.get(line_ix.saturating_add(1))
|
||||
.copied()
|
||||
.unwrap_or(text_len)
|
||||
.min(text_len);
|
||||
if end > start && text.as_bytes().get(end.saturating_sub(1)) == Some(&b'\n') {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
Some(text.get(start..end).unwrap_or(""))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct ThreeWayConflictMaps {
|
||||
pub conflict_ranges: Vec<std::ops::Range<usize>>,
|
||||
|
|
@ -1217,21 +1245,30 @@ pub fn build_three_way_visible_map(
|
|||
.collect();
|
||||
|
||||
let mut visible = Vec::with_capacity(total_lines);
|
||||
let mut line = 0usize;
|
||||
while line < total_lines {
|
||||
if let Some((range_ix, range)) = conflict_ranges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, r)| r.contains(&line))
|
||||
.filter(|(ri, _)| resolved_blocks.get(*ri).copied().unwrap_or(false))
|
||||
let mut line_ix = 0usize;
|
||||
let mut range_ix = 0usize;
|
||||
|
||||
while line_ix < total_lines {
|
||||
while let Some(range) = conflict_ranges.get(range_ix) {
|
||||
if range.end <= line_ix {
|
||||
range_ix += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(range) = conflict_ranges.get(range_ix)
|
||||
&& range.contains(&line_ix)
|
||||
&& resolved_blocks.get(range_ix).copied().unwrap_or(false)
|
||||
{
|
||||
// Emit one collapsed summary row and skip the rest of the range.
|
||||
visible.push(ThreeWayVisibleItem::CollapsedBlock(range_ix));
|
||||
line = range.end;
|
||||
line_ix = range.end;
|
||||
continue;
|
||||
}
|
||||
visible.push(ThreeWayVisibleItem::Line(line));
|
||||
line += 1;
|
||||
|
||||
visible.push(ThreeWayVisibleItem::Line(line_ix));
|
||||
line_ix += 1;
|
||||
}
|
||||
visible
|
||||
}
|
||||
|
|
@ -1267,15 +1304,17 @@ pub fn unresolved_visible_nav_entries_for_three_way(
|
|||
}
|
||||
|
||||
pub fn compute_three_way_word_highlights(
|
||||
base_lines: &[gpui::SharedString],
|
||||
ours_lines: &[gpui::SharedString],
|
||||
theirs_lines: &[gpui::SharedString],
|
||||
base_text: &str,
|
||||
base_line_starts: &[usize],
|
||||
ours_text: &str,
|
||||
ours_line_starts: &[usize],
|
||||
theirs_text: &str,
|
||||
theirs_line_starts: &[usize],
|
||||
marker_segments: &[ConflictSegment],
|
||||
) -> (WordHighlights, WordHighlights, WordHighlights) {
|
||||
let len = base_lines
|
||||
.len()
|
||||
.max(ours_lines.len())
|
||||
.max(theirs_lines.len());
|
||||
let len = indexed_line_count(base_text, base_line_starts)
|
||||
.max(indexed_line_count(ours_text, ours_line_starts))
|
||||
.max(indexed_line_count(theirs_text, theirs_line_starts));
|
||||
let mut wh_base: WordHighlights = vec![None; len];
|
||||
let mut wh_ours: WordHighlights = vec![None; len];
|
||||
let mut wh_theirs: WordHighlights = vec![None; len];
|
||||
|
|
@ -1307,10 +1346,11 @@ pub fn compute_three_way_word_highlights(
|
|||
}
|
||||
|
||||
fn full_line_range(
|
||||
lines: &[gpui::SharedString],
|
||||
text: &str,
|
||||
line_starts: &[usize],
|
||||
line_ix: usize,
|
||||
) -> Vec<std::ops::Range<usize>> {
|
||||
let Some(line) = lines.get(line_ix).map(|s| s.as_ref()) else {
|
||||
let Some(line) = indexed_line_text(text, line_starts, line_ix) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if line.is_empty() {
|
||||
|
|
@ -1321,7 +1361,8 @@ pub fn compute_three_way_word_highlights(
|
|||
|
||||
struct HighlightSide<'a> {
|
||||
global_start: usize,
|
||||
lines: &'a [gpui::SharedString],
|
||||
text: &'a str,
|
||||
line_starts: &'a [usize],
|
||||
}
|
||||
|
||||
fn apply_aligned_word_highlights(
|
||||
|
|
@ -1352,12 +1393,20 @@ pub fn compute_three_way_word_highlights(
|
|||
}
|
||||
FileDiffRowKind::Remove => {
|
||||
if let Some(ix) = line_index(old_side.global_start, row.old_line) {
|
||||
merge_line_ranges(old_highlights, ix, full_line_range(old_side.lines, ix));
|
||||
merge_line_ranges(
|
||||
old_highlights,
|
||||
ix,
|
||||
full_line_range(old_side.text, old_side.line_starts, ix),
|
||||
);
|
||||
}
|
||||
}
|
||||
FileDiffRowKind::Add => {
|
||||
if let Some(ix) = line_index(new_side.global_start, row.new_line) {
|
||||
merge_line_ranges(new_highlights, ix, full_line_range(new_side.lines, ix));
|
||||
merge_line_ranges(
|
||||
new_highlights,
|
||||
ix,
|
||||
full_line_range(new_side.text, new_side.line_starts, ix),
|
||||
);
|
||||
}
|
||||
}
|
||||
FileDiffRowKind::Context => {}
|
||||
|
|
@ -1383,11 +1432,13 @@ pub fn compute_three_way_word_highlights(
|
|||
&block.ours,
|
||||
HighlightSide {
|
||||
global_start: base_offset,
|
||||
lines: base_lines,
|
||||
text: base_text,
|
||||
line_starts: base_line_starts,
|
||||
},
|
||||
HighlightSide {
|
||||
global_start: ours_offset,
|
||||
lines: ours_lines,
|
||||
text: ours_text,
|
||||
line_starts: ours_line_starts,
|
||||
},
|
||||
&mut wh_base,
|
||||
&mut wh_ours,
|
||||
|
|
@ -1397,11 +1448,13 @@ pub fn compute_three_way_word_highlights(
|
|||
&block.theirs,
|
||||
HighlightSide {
|
||||
global_start: base_offset,
|
||||
lines: base_lines,
|
||||
text: base_text,
|
||||
line_starts: base_line_starts,
|
||||
},
|
||||
HighlightSide {
|
||||
global_start: theirs_offset,
|
||||
lines: theirs_lines,
|
||||
text: theirs_text,
|
||||
line_starts: theirs_line_starts,
|
||||
},
|
||||
&mut wh_base,
|
||||
&mut wh_theirs,
|
||||
|
|
@ -1413,11 +1466,13 @@ pub fn compute_three_way_word_highlights(
|
|||
&block.theirs,
|
||||
HighlightSide {
|
||||
global_start: ours_offset,
|
||||
lines: ours_lines,
|
||||
text: ours_text,
|
||||
line_starts: ours_line_starts,
|
||||
},
|
||||
HighlightSide {
|
||||
global_start: theirs_offset,
|
||||
lines: theirs_lines,
|
||||
text: theirs_text,
|
||||
line_starts: theirs_line_starts,
|
||||
},
|
||||
&mut wh_ours,
|
||||
&mut wh_theirs,
|
||||
|
|
@ -1586,6 +1641,12 @@ pub fn split_output_lines_for_outline(output: &str) -> Vec<String> {
|
|||
output.split('\n').map(|line| line.to_string()).collect()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn output_line_count_for_outline(output: &str) -> usize {
|
||||
output.as_bytes().iter().filter(|&&b| b == b'\n').count() + 1
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn append_lines_to_output(output: &str, lines: &[String]) -> String {
|
||||
if lines.is_empty() {
|
||||
return output.to_string();
|
||||
|
|
@ -1623,50 +1684,51 @@ pub struct SourceLines<'a> {
|
|||
pub c: &'a [gpui::SharedString],
|
||||
}
|
||||
|
||||
/// Compute per-line provenance metadata for the resolved output.
|
||||
///
|
||||
/// Each output line is compared (exact text equality) against every source line
|
||||
/// in A, B, C. The first match found (priority: A, B, C) wins; if none match
|
||||
/// the line is labeled `Manual`.
|
||||
pub fn compute_resolved_line_provenance(
|
||||
output_lines: &[String],
|
||||
sources: &SourceLines<'_>,
|
||||
fn build_source_line_lookup<'a>(
|
||||
sources: &'a SourceLines<'a>,
|
||||
) -> rustc_hash::FxHashMap<&'a str, (ResolvedLineSource, u32)> {
|
||||
let mut lookup = rustc_hash::FxHashMap::default();
|
||||
|
||||
// Insert in reverse order so duplicates keep the first line number within a side.
|
||||
// Later sides overwrite earlier ones to enforce priority A > B > C.
|
||||
for (ix, line) in sources.c.iter().enumerate().rev() {
|
||||
lookup.insert(
|
||||
line.as_ref(),
|
||||
(
|
||||
ResolvedLineSource::C,
|
||||
u32::try_from(ix + 1).unwrap_or(u32::MAX),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (ix, line) in sources.b.iter().enumerate().rev() {
|
||||
lookup.insert(
|
||||
line.as_ref(),
|
||||
(
|
||||
ResolvedLineSource::B,
|
||||
u32::try_from(ix + 1).unwrap_or(u32::MAX),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (ix, line) in sources.a.iter().enumerate().rev() {
|
||||
lookup.insert(
|
||||
line.as_ref(),
|
||||
(
|
||||
ResolvedLineSource::A,
|
||||
u32::try_from(ix + 1).unwrap_or(u32::MAX),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lookup
|
||||
}
|
||||
|
||||
fn compute_resolved_line_provenance_from_iter<'a>(
|
||||
output_lines: impl Iterator<Item = &'a str>,
|
||||
lookup: &rustc_hash::FxHashMap<&str, (ResolvedLineSource, u32)>,
|
||||
) -> Vec<ResolvedLineMeta> {
|
||||
// Build lookup tables: content -> Vec<(source, 1-based line_no)>
|
||||
// We iterate sources in priority order (A, B, C) and take the first match.
|
||||
let mut result = Vec::with_capacity(output_lines.len());
|
||||
|
||||
for (out_ix, out_line) in output_lines.iter().enumerate() {
|
||||
let trimmed = out_line.as_str();
|
||||
let mut found = None;
|
||||
|
||||
// Check A
|
||||
for (i, src_line) in sources.a.iter().enumerate() {
|
||||
if src_line.as_ref() == trimmed {
|
||||
found = Some((ResolvedLineSource::A, (i + 1) as u32));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Check B (only if A didn't match)
|
||||
if found.is_none() {
|
||||
for (i, src_line) in sources.b.iter().enumerate() {
|
||||
if src_line.as_ref() == trimmed {
|
||||
found = Some((ResolvedLineSource::B, (i + 1) as u32));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check C (only if A and B didn't match)
|
||||
if found.is_none() {
|
||||
for (i, src_line) in sources.c.iter().enumerate() {
|
||||
if src_line.as_ref() == trimmed {
|
||||
found = Some((ResolvedLineSource::C, (i + 1) as u32));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (source, input_line) = match found {
|
||||
let mut result = Vec::new();
|
||||
for (out_ix, out_line) in output_lines.enumerate() {
|
||||
let (source, input_line) = match lookup.get(out_line).copied() {
|
||||
Some((src, line_no)) => (src, Some(line_no)),
|
||||
None => (ResolvedLineSource::Manual, None),
|
||||
};
|
||||
|
|
@ -1676,10 +1738,105 @@ pub fn compute_resolved_line_provenance(
|
|||
input_line,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute per-line provenance metadata for the resolved output.
|
||||
///
|
||||
/// Each output line is compared (exact text equality) against every source line
|
||||
/// in A, B, C. The first match found (priority: A, B, C) wins; if none match
|
||||
/// the line is labeled `Manual`.
|
||||
pub fn compute_resolved_line_provenance(
|
||||
output_lines: &[String],
|
||||
sources: &SourceLines<'_>,
|
||||
) -> Vec<ResolvedLineMeta> {
|
||||
let lookup = build_source_line_lookup(sources);
|
||||
compute_resolved_line_provenance_from_iter(output_lines.iter().map(String::as_str), &lookup)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn compute_resolved_line_provenance_from_text(
|
||||
output_text: &str,
|
||||
sources: &SourceLines<'_>,
|
||||
) -> Vec<ResolvedLineMeta> {
|
||||
let lookup = build_source_line_lookup(sources);
|
||||
compute_resolved_line_provenance_from_iter(output_text.split('\n'), &lookup)
|
||||
}
|
||||
|
||||
fn insert_indexed_source_lines<'a>(
|
||||
lookup: &mut rustc_hash::FxHashMap<&'a str, (ResolvedLineSource, u32)>,
|
||||
source: ResolvedLineSource,
|
||||
text: &'a str,
|
||||
line_starts: &[usize],
|
||||
) {
|
||||
let line_count = indexed_line_count(text, line_starts);
|
||||
for line_ix in (0..line_count).rev() {
|
||||
if let Some(line) = indexed_line_text(text, line_starts, line_ix) {
|
||||
lookup.insert(
|
||||
line,
|
||||
(
|
||||
source,
|
||||
u32::try_from(line_ix.saturating_add(1)).unwrap_or(u32::MAX),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_resolved_line_provenance_from_text_with_indexed_sources(
|
||||
output_text: &str,
|
||||
a_text: &str,
|
||||
a_line_starts: &[usize],
|
||||
b_text: &str,
|
||||
b_line_starts: &[usize],
|
||||
c_text: &str,
|
||||
c_line_starts: &[usize],
|
||||
) -> Vec<ResolvedLineMeta> {
|
||||
let mut lookup = rustc_hash::FxHashMap::default();
|
||||
insert_indexed_source_lines(&mut lookup, ResolvedLineSource::C, c_text, c_line_starts);
|
||||
insert_indexed_source_lines(&mut lookup, ResolvedLineSource::B, b_text, b_line_starts);
|
||||
insert_indexed_source_lines(&mut lookup, ResolvedLineSource::A, a_text, a_line_starts);
|
||||
compute_resolved_line_provenance_from_iter(output_text.split('\n'), &lookup)
|
||||
}
|
||||
|
||||
fn insert_two_way_side_lookup<'a>(
|
||||
lookup: &mut rustc_hash::FxHashMap<&'a str, (ResolvedLineSource, u32)>,
|
||||
rows: &'a [gitcomet_core::file_diff::FileDiffRow],
|
||||
source: ResolvedLineSource,
|
||||
read_text: impl Fn(&'a gitcomet_core::file_diff::FileDiffRow) -> Option<&'a str>,
|
||||
) {
|
||||
let mut line_no = rows
|
||||
.iter()
|
||||
.filter_map(&read_text)
|
||||
.count()
|
||||
.min(u32::MAX as usize) as u32;
|
||||
for row in rows.iter().rev() {
|
||||
let Some(text) = read_text(row) else {
|
||||
continue;
|
||||
};
|
||||
if line_no == 0 {
|
||||
continue;
|
||||
}
|
||||
lookup.insert(text, (source, line_no));
|
||||
line_no = line_no.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_resolved_line_provenance_from_text_two_way_rows(
|
||||
output_text: &str,
|
||||
diff_rows: &[gitcomet_core::file_diff::FileDiffRow],
|
||||
) -> Vec<ResolvedLineMeta> {
|
||||
let mut lookup = rustc_hash::FxHashMap::default();
|
||||
// Reverse insertion to preserve side priority A > B for duplicate lines.
|
||||
insert_two_way_side_lookup(&mut lookup, diff_rows, ResolvedLineSource::B, |row| {
|
||||
row.new.as_deref()
|
||||
});
|
||||
insert_two_way_side_lookup(&mut lookup, diff_rows, ResolvedLineSource::A, |row| {
|
||||
row.old.as_deref()
|
||||
});
|
||||
compute_resolved_line_provenance_from_iter(output_text.split('\n'), &lookup)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedupe key index: tracks which source lines are present in resolved output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1688,6 +1845,7 @@ pub fn compute_resolved_line_provenance(
|
|||
///
|
||||
/// Used to gate the plus-icon: a source row's plus-icon is hidden when its key
|
||||
/// is already in this set (preventing duplicate insertion).
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn build_resolved_output_line_sources_index(
|
||||
meta: &[ResolvedLineMeta],
|
||||
output_lines: &[String],
|
||||
|
|
@ -1710,6 +1868,27 @@ pub fn build_resolved_output_line_sources_index(
|
|||
index
|
||||
}
|
||||
|
||||
pub fn build_resolved_output_line_sources_index_from_text(
|
||||
meta: &[ResolvedLineMeta],
|
||||
output_text: &str,
|
||||
view_mode: ConflictResolverViewMode,
|
||||
) -> rustc_hash::FxHashSet<SourceLineKey> {
|
||||
let mut index = rustc_hash::FxHashSet::with_capacity_and_hasher(meta.len(), Default::default());
|
||||
for (ix, line) in output_text.split('\n').enumerate() {
|
||||
let Some(m) = meta.get(ix) else {
|
||||
break;
|
||||
};
|
||||
if m.source == ResolvedLineSource::Manual {
|
||||
continue;
|
||||
}
|
||||
let Some(line_no) = m.input_line else {
|
||||
continue;
|
||||
};
|
||||
index.insert(SourceLineKey::new(view_mode, m.source, line_no, line));
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Check whether a given source line is already present in the resolved output.
|
||||
///
|
||||
/// Returns `true` if the source line's key is in the dedupe index — meaning
|
||||
|
|
|
|||
|
|
@ -1885,6 +1885,43 @@ fn two_way_visible_indices_hide_only_resolved_conflict_rows() {
|
|||
|
||||
// -- hide-resolved visible map tests --
|
||||
|
||||
fn build_three_way_visible_map_legacy(
|
||||
total_lines: usize,
|
||||
conflict_ranges: &[std::ops::Range<usize>],
|
||||
segments: &[ConflictSegment],
|
||||
hide_resolved: bool,
|
||||
) -> Vec<ThreeWayVisibleItem> {
|
||||
if !hide_resolved {
|
||||
return (0..total_lines).map(ThreeWayVisibleItem::Line).collect();
|
||||
}
|
||||
|
||||
let resolved_blocks: Vec<bool> = segments
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
ConflictSegment::Block(b) => Some(b.resolved),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut visible = Vec::with_capacity(total_lines);
|
||||
let mut line = 0usize;
|
||||
while line < total_lines {
|
||||
if let Some((range_ix, range)) = conflict_ranges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, r)| r.contains(&line))
|
||||
.filter(|(ri, _)| resolved_blocks.get(*ri).copied().unwrap_or(false))
|
||||
{
|
||||
visible.push(ThreeWayVisibleItem::CollapsedBlock(range_ix));
|
||||
line = range.end;
|
||||
continue;
|
||||
}
|
||||
visible.push(ThreeWayVisibleItem::Line(line));
|
||||
line += 1;
|
||||
}
|
||||
visible
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_map_identity_when_not_hiding() {
|
||||
// 3 lines of text, 1 conflict with 2 lines = 5 total lines
|
||||
|
|
@ -1962,6 +1999,110 @@ fn visible_map_keeps_unresolved_blocks_expanded() {
|
|||
assert_eq!(map[3], ThreeWayVisibleItem::CollapsedBlock(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_map_matches_legacy_scan_with_empty_and_trailing_ranges() {
|
||||
let segments = vec![
|
||||
ConflictSegment::Block(ConflictBlock {
|
||||
base: None,
|
||||
ours: "a\n".into(),
|
||||
theirs: "A\n".into(),
|
||||
choice: ConflictChoice::Ours,
|
||||
resolved: true,
|
||||
}),
|
||||
ConflictSegment::Block(ConflictBlock {
|
||||
base: None,
|
||||
ours: "b\nc\n".into(),
|
||||
theirs: "B\nC\n".into(),
|
||||
choice: ConflictChoice::Ours,
|
||||
resolved: false,
|
||||
}),
|
||||
ConflictSegment::Block(ConflictBlock {
|
||||
base: None,
|
||||
ours: "d\ne\n".into(),
|
||||
theirs: "D\nE\n".into(),
|
||||
choice: ConflictChoice::Theirs,
|
||||
resolved: true,
|
||||
}),
|
||||
];
|
||||
let ranges = vec![0..0, 1..3, 4..6, 7..9];
|
||||
let linear = build_three_way_visible_map(9, &ranges, &segments, true);
|
||||
let legacy = build_three_way_visible_map_legacy(9, &ranges, &segments, true);
|
||||
assert_eq!(linear, legacy);
|
||||
assert_eq!(
|
||||
linear,
|
||||
vec![
|
||||
ThreeWayVisibleItem::Line(0),
|
||||
ThreeWayVisibleItem::Line(1),
|
||||
ThreeWayVisibleItem::Line(2),
|
||||
ThreeWayVisibleItem::Line(3),
|
||||
ThreeWayVisibleItem::CollapsedBlock(2),
|
||||
ThreeWayVisibleItem::Line(6),
|
||||
ThreeWayVisibleItem::Line(7),
|
||||
ThreeWayVisibleItem::Line(8),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_map_linear_walk_outpaces_legacy_scan() {
|
||||
use std::time::Instant;
|
||||
|
||||
let conflict_count = 5_000usize;
|
||||
let total_lines = conflict_count.saturating_mul(2).saturating_add(1);
|
||||
let ranges: Vec<std::ops::Range<usize>> = (0..conflict_count)
|
||||
.map(|ix| {
|
||||
let start = ix.saturating_mul(2).saturating_add(1);
|
||||
start..start.saturating_add(1)
|
||||
})
|
||||
.collect();
|
||||
let segments: Vec<ConflictSegment> = (0..conflict_count)
|
||||
.map(|ix| {
|
||||
ConflictSegment::Block(ConflictBlock {
|
||||
base: None,
|
||||
ours: "ours\n".into(),
|
||||
theirs: "theirs\n".into(),
|
||||
choice: ConflictChoice::Ours,
|
||||
resolved: ix % 3 != 0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let linear_map = build_three_way_visible_map(total_lines, &ranges, &segments, true);
|
||||
let legacy_map = build_three_way_visible_map_legacy(total_lines, &ranges, &segments, true);
|
||||
assert_eq!(linear_map, legacy_map);
|
||||
|
||||
let iterations = 6usize;
|
||||
|
||||
let linear_start = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
std::hint::black_box(build_three_way_visible_map(
|
||||
total_lines,
|
||||
&ranges,
|
||||
&segments,
|
||||
true,
|
||||
));
|
||||
}
|
||||
let linear_elapsed = linear_start.elapsed();
|
||||
|
||||
let legacy_start = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
std::hint::black_box(build_three_way_visible_map_legacy(
|
||||
total_lines,
|
||||
&ranges,
|
||||
&segments,
|
||||
true,
|
||||
));
|
||||
}
|
||||
let legacy_elapsed = legacy_start.elapsed();
|
||||
|
||||
let linear_ns = linear_elapsed.as_nanos().max(1);
|
||||
let legacy_ns = legacy_elapsed.as_nanos().max(1);
|
||||
assert!(
|
||||
linear_ns.saturating_mul(4) < legacy_ns,
|
||||
"expected linear walk to be >=4x faster than legacy scan, got linear={linear_elapsed:?}, legacy={legacy_elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_index_for_conflict_finds_collapsed() {
|
||||
let segments = vec![
|
||||
|
|
@ -2804,8 +2945,16 @@ fn two_way_conflict_index_for_visible_row_maps_back_to_conflict() {
|
|||
|
||||
#[test]
|
||||
fn three_way_word_highlights_align_shifted_local_and_remote_rows() {
|
||||
fn shared_lines(text: &str) -> Vec<gpui::SharedString> {
|
||||
text.lines().map(|line| line.to_string().into()).collect()
|
||||
fn line_starts(text: &str) -> Vec<usize> {
|
||||
let mut starts =
|
||||
Vec::with_capacity(text.as_bytes().iter().filter(|&&b| b == b'\n').count() + 1);
|
||||
starts.push(0);
|
||||
for (ix, byte) in text.as_bytes().iter().enumerate() {
|
||||
if *byte == b'\n' {
|
||||
starts.push(ix + 1);
|
||||
}
|
||||
}
|
||||
starts
|
||||
}
|
||||
|
||||
let marker_segments = vec![ConflictSegment::Block(ConflictBlock {
|
||||
|
|
@ -2815,14 +2964,17 @@ fn three_way_word_highlights_align_shifted_local_and_remote_rows() {
|
|||
choice: ConflictChoice::Ours,
|
||||
resolved: false,
|
||||
})];
|
||||
let base_lines = Vec::new();
|
||||
let ours_lines = shared_lines("alpha\nbeta changed\ngamma\n");
|
||||
let theirs_lines = shared_lines("alpha\ninserted\nbeta remote\ngamma\n");
|
||||
let base_text = "";
|
||||
let ours_text = "alpha\nbeta changed\ngamma\n";
|
||||
let theirs_text = "alpha\ninserted\nbeta remote\ngamma\n";
|
||||
|
||||
let (_base_hl, ours_hl, theirs_hl) = compute_three_way_word_highlights(
|
||||
&base_lines,
|
||||
&ours_lines,
|
||||
&theirs_lines,
|
||||
base_text,
|
||||
&line_starts(base_text),
|
||||
ours_text,
|
||||
&line_starts(ours_text),
|
||||
theirs_text,
|
||||
&line_starts(theirs_text),
|
||||
&marker_segments,
|
||||
);
|
||||
|
||||
|
|
@ -2855,8 +3007,16 @@ fn three_way_word_highlights_align_shifted_local_and_remote_rows() {
|
|||
|
||||
#[test]
|
||||
fn three_way_word_highlights_keep_global_offsets_per_column() {
|
||||
fn shared_lines(text: &str) -> Vec<gpui::SharedString> {
|
||||
text.lines().map(|line| line.to_string().into()).collect()
|
||||
fn line_starts(text: &str) -> Vec<usize> {
|
||||
let mut starts =
|
||||
Vec::with_capacity(text.as_bytes().iter().filter(|&&b| b == b'\n').count() + 1);
|
||||
starts.push(0);
|
||||
for (ix, byte) in text.as_bytes().iter().enumerate() {
|
||||
if *byte == b'\n' {
|
||||
starts.push(ix + 1);
|
||||
}
|
||||
}
|
||||
starts
|
||||
}
|
||||
|
||||
let marker_segments = vec![
|
||||
|
|
@ -2870,14 +3030,17 @@ fn three_way_word_highlights_keep_global_offsets_per_column() {
|
|||
}),
|
||||
ConflictSegment::Text("tail\n".into()),
|
||||
];
|
||||
let base_lines = Vec::new();
|
||||
let ours_lines = shared_lines("ctx\nsame\ntail\n");
|
||||
let theirs_lines = shared_lines("ctx\nadded\nsame\ntail\n");
|
||||
let base_text = "";
|
||||
let ours_text = "ctx\nsame\ntail\n";
|
||||
let theirs_text = "ctx\nadded\nsame\ntail\n";
|
||||
|
||||
let (_base_hl, ours_hl, theirs_hl) = compute_three_way_word_highlights(
|
||||
&base_lines,
|
||||
&ours_lines,
|
||||
&theirs_lines,
|
||||
base_text,
|
||||
&line_starts(base_text),
|
||||
ours_text,
|
||||
&line_starts(ours_text),
|
||||
theirs_text,
|
||||
&line_starts(theirs_text),
|
||||
&marker_segments,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use gitcomet_core::diff::AnnotatedDiffLine;
|
||||
use crate::view::diff_utils::UnifiedDiffLine;
|
||||
use gitcomet_core::domain::DiffTarget;
|
||||
|
||||
pub(super) fn build_new_file_preview_from_diff(
|
||||
diff: &[AnnotatedDiffLine],
|
||||
diff: &[impl UnifiedDiffLine],
|
||||
workdir: &std::path::Path,
|
||||
target: Option<&DiffTarget>,
|
||||
) -> Option<(std::path::PathBuf, Vec<String>)> {
|
||||
|
|
@ -11,18 +11,18 @@ pub(super) fn build_new_file_preview_from_diff(
|
|||
let mut has_remove = false;
|
||||
|
||||
for line in diff {
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ")
|
||||
if matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text().starts_with("diff --git ")
|
||||
{
|
||||
file_header_count += 1;
|
||||
}
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& (line.text.starts_with("new file mode ")
|
||||
|| line.text.eq_ignore_ascii_case("--- /dev/null"))
|
||||
if matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& (line.text().starts_with("new file mode ")
|
||||
|| line.text().eq_ignore_ascii_case("--- /dev/null"))
|
||||
{
|
||||
is_new_file = true;
|
||||
}
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Remove) {
|
||||
if matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Remove) {
|
||||
has_remove = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,15 +47,15 @@ pub(super) fn build_new_file_preview_from_diff(
|
|||
|
||||
let lines = diff
|
||||
.iter()
|
||||
.filter(|l| matches!(l.kind, gitcomet_core::domain::DiffLineKind::Add))
|
||||
.map(|l| l.text.strip_prefix('+').unwrap_or(&l.text).to_string())
|
||||
.filter(|l| matches!(l.kind(), gitcomet_core::domain::DiffLineKind::Add))
|
||||
.map(|l| l.text().strip_prefix('+').unwrap_or(l.text()).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some((abs_path, lines))
|
||||
}
|
||||
|
||||
pub(super) fn build_deleted_file_preview_from_diff(
|
||||
diff: &[AnnotatedDiffLine],
|
||||
diff: &[impl UnifiedDiffLine],
|
||||
workdir: &std::path::Path,
|
||||
target: Option<&DiffTarget>,
|
||||
) -> Option<(std::path::PathBuf, Vec<String>)> {
|
||||
|
|
@ -64,18 +64,18 @@ pub(super) fn build_deleted_file_preview_from_diff(
|
|||
let mut has_add = false;
|
||||
|
||||
for line in diff {
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ")
|
||||
if matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text().starts_with("diff --git ")
|
||||
{
|
||||
file_header_count += 1;
|
||||
}
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& (line.text.starts_with("deleted file mode ")
|
||||
|| line.text.eq_ignore_ascii_case("+++ /dev/null"))
|
||||
if matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& (line.text().starts_with("deleted file mode ")
|
||||
|| line.text().eq_ignore_ascii_case("+++ /dev/null"))
|
||||
{
|
||||
is_deleted_file = true;
|
||||
}
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Add) {
|
||||
if matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Add) {
|
||||
has_add = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -100,8 +100,8 @@ pub(super) fn build_deleted_file_preview_from_diff(
|
|||
|
||||
let lines = diff
|
||||
.iter()
|
||||
.filter(|l| matches!(l.kind, gitcomet_core::domain::DiffLineKind::Remove))
|
||||
.map(|l| l.text.strip_prefix('-').unwrap_or(&l.text).to_string())
|
||||
.filter(|l| matches!(l.kind(), gitcomet_core::domain::DiffLineKind::Remove))
|
||||
.map(|l| l.text().strip_prefix('-').unwrap_or(l.text()).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some((abs_path, lines))
|
||||
|
|
@ -110,6 +110,7 @@ pub(super) fn build_deleted_file_preview_from_diff(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gitcomet_core::diff::AnnotatedDiffLine;
|
||||
use gitcomet_core::domain::{DiffArea, DiffLineKind};
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ pub(super) fn rasterize_svg_preview_image(svg_bytes: &[u8]) -> Option<Arc<gpui::
|
|||
)))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn compute_diff_word_highlights(
|
||||
diff: &[AnnotatedDiffLine],
|
||||
) -> Vec<Option<Vec<Range<usize>>>> {
|
||||
|
|
@ -238,7 +239,9 @@ pub(super) fn parse_unified_hunk_header_for_display(text: &str) -> Option<Parsed
|
|||
})
|
||||
}
|
||||
|
||||
pub(super) fn compute_diff_file_stats(diff: &[AnnotatedDiffLine]) -> Vec<Option<(usize, usize)>> {
|
||||
pub(super) fn compute_diff_file_stats(
|
||||
diff: &[impl UnifiedDiffLine],
|
||||
) -> Vec<Option<(usize, usize)>> {
|
||||
let mut stats: Vec<Option<(usize, usize)>> = vec![None; diff.len()];
|
||||
|
||||
let mut current_file_header_ix: Option<usize> = None;
|
||||
|
|
@ -246,8 +249,8 @@ pub(super) fn compute_diff_file_stats(diff: &[AnnotatedDiffLine]) -> Vec<Option<
|
|||
let mut removes = 0usize;
|
||||
|
||||
for (ix, line) in diff.iter().enumerate() {
|
||||
let is_file_header = matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ");
|
||||
let is_file_header = matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text().starts_with("diff --git ");
|
||||
|
||||
if is_file_header {
|
||||
if let Some(header_ix) = current_file_header_ix.take() {
|
||||
|
|
@ -259,7 +262,7 @@ pub(super) fn compute_diff_file_stats(diff: &[AnnotatedDiffLine]) -> Vec<Option<
|
|||
continue;
|
||||
}
|
||||
|
||||
match line.kind {
|
||||
match line.kind() {
|
||||
gitcomet_core::domain::DiffLineKind::Add => adds += 1,
|
||||
gitcomet_core::domain::DiffLineKind::Remove => removes += 1,
|
||||
_ => {}
|
||||
|
|
@ -273,15 +276,15 @@ pub(super) fn compute_diff_file_stats(diff: &[AnnotatedDiffLine]) -> Vec<Option<
|
|||
stats
|
||||
}
|
||||
|
||||
pub(super) fn compute_diff_file_for_src_ix(diff: &[AnnotatedDiffLine]) -> Vec<Option<Arc<str>>> {
|
||||
pub(super) fn compute_diff_file_for_src_ix(diff: &[impl UnifiedDiffLine]) -> Vec<Option<Arc<str>>> {
|
||||
let mut out: Vec<Option<Arc<str>>> = Vec::with_capacity(diff.len());
|
||||
let mut current_file: Option<Arc<str>> = None;
|
||||
|
||||
for line in diff {
|
||||
let is_file_header = matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ");
|
||||
let is_file_header = matches!(line.kind(), gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text().starts_with("diff --git ");
|
||||
if is_file_header {
|
||||
current_file = parse_diff_git_header_path(&line.text).map(Arc::<str>::from);
|
||||
current_file = parse_diff_git_header_path(line.text()).map(Arc::<str>::from);
|
||||
}
|
||||
out.push(current_file.clone());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ use diff_text_selection::{DiffTextSelectionOverlay, DiffTextSelectionTracker};
|
|||
use diff_utils::{
|
||||
build_unified_patch_for_hunks, build_unified_patch_for_selected_lines_across_hunks,
|
||||
build_unified_patch_for_selected_lines_across_hunks_for_worktree_discard,
|
||||
compute_diff_file_for_src_ix, compute_diff_file_stats, compute_diff_word_highlights,
|
||||
context_menu_selection_range_from_diff_text, diff_content_text, enclosing_hunk_src_ix,
|
||||
image_format_for_path, parse_diff_git_header_path, parse_unified_hunk_header_for_display,
|
||||
rasterize_svg_preview_image, rasterize_svg_preview_png, scrollbar_markers_from_flags,
|
||||
compute_diff_file_for_src_ix, compute_diff_file_stats,
|
||||
context_menu_selection_range_from_diff_text, diff_content_text, image_format_for_path,
|
||||
parse_diff_git_header_path, parse_unified_hunk_header_for_display, rasterize_svg_preview_image,
|
||||
rasterize_svg_preview_png, scrollbar_markers_from_flags,
|
||||
};
|
||||
use mod_helpers::*;
|
||||
pub use mod_helpers::{
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ pub(super) fn is_svg_path(path: &std::path::Path) -> bool {
|
|||
.is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
|
||||
}
|
||||
|
||||
pub(super) fn is_existing_directory(path: &std::path::Path) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|meta| meta.is_dir())
|
||||
}
|
||||
|
||||
pub(super) fn is_existing_regular_file(path: &std::path::Path) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
|
||||
}
|
||||
|
||||
pub(super) fn should_bypass_text_file_preview_for_path(path: &std::path::Path) -> bool {
|
||||
let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
|
||||
return false;
|
||||
|
|
@ -212,6 +220,23 @@ mod resize_drag_ghost_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod directory_path_tests {
|
||||
use super::is_existing_directory;
|
||||
|
||||
#[test]
|
||||
fn detects_existing_directory_paths() {
|
||||
let tmp = std::env::temp_dir().join(format!("gitcomet_is_dir_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).expect("create temp directory");
|
||||
|
||||
assert!(is_existing_directory(&tmp));
|
||||
assert!(!is_existing_directory(&tmp.join("missing")));
|
||||
|
||||
std::fs::remove_dir_all(&tmp).expect("cleanup temp directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub(super) enum DiffTextRegion {
|
||||
Inline,
|
||||
|
|
@ -351,7 +376,10 @@ pub(super) struct ConflictResolverUiState {
|
|||
pub(super) view_mode: ConflictResolverViewMode,
|
||||
pub(super) diff_rows: Vec<FileDiffRow>,
|
||||
pub(super) inline_rows: Vec<ConflictInlineRow>,
|
||||
pub(super) three_way_lines: ThreeWaySides<Vec<SharedString>>,
|
||||
/// Backing text for each three-way source side.
|
||||
pub(super) three_way_text: ThreeWaySides<SharedString>,
|
||||
/// Per-side line start offsets into `three_way_text`.
|
||||
pub(super) three_way_line_starts: ThreeWaySides<Vec<usize>>,
|
||||
pub(super) three_way_len: usize,
|
||||
pub(super) three_way_conflict_ranges: Vec<Range<usize>>,
|
||||
pub(super) three_way_line_conflict_map: ThreeWaySides<Vec<Option<usize>>>,
|
||||
|
|
@ -407,7 +435,8 @@ impl Default for ConflictResolverUiState {
|
|||
view_mode: ConflictResolverViewMode::TwoWayDiff,
|
||||
diff_rows: Vec::new(),
|
||||
inline_rows: Vec::new(),
|
||||
three_way_lines: ThreeWaySides::default(),
|
||||
three_way_text: ThreeWaySides::default(),
|
||||
three_way_line_starts: ThreeWaySides::default(),
|
||||
three_way_len: 0,
|
||||
three_way_conflict_ranges: Vec::new(),
|
||||
three_way_line_conflict_map: ThreeWaySides::default(),
|
||||
|
|
@ -437,6 +466,75 @@ impl Default for ConflictResolverUiState {
|
|||
}
|
||||
}
|
||||
|
||||
fn indexed_line_count(text: &str, line_starts: &[usize]) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
line_starts.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_line_text<'a>(text: &'a str, line_starts: &[usize], line_ix: usize) -> Option<&'a str> {
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let text_len = text.len();
|
||||
let start = line_starts.get(line_ix).copied().unwrap_or(text_len);
|
||||
if start >= text_len {
|
||||
return None;
|
||||
}
|
||||
let mut end = line_starts
|
||||
.get(line_ix.saturating_add(1))
|
||||
.copied()
|
||||
.unwrap_or(text_len)
|
||||
.min(text_len);
|
||||
if end > start && text.as_bytes().get(end.saturating_sub(1)) == Some(&b'\n') {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
Some(text.get(start..end).unwrap_or(""))
|
||||
}
|
||||
|
||||
impl ConflictResolverUiState {
|
||||
pub(super) fn three_way_line_count(&self, side: ThreeWayColumn) -> usize {
|
||||
match side {
|
||||
ThreeWayColumn::Base => {
|
||||
indexed_line_count(&self.three_way_text.base, &self.three_way_line_starts.base)
|
||||
}
|
||||
ThreeWayColumn::Ours => {
|
||||
indexed_line_count(&self.three_way_text.ours, &self.three_way_line_starts.ours)
|
||||
}
|
||||
ThreeWayColumn::Theirs => indexed_line_count(
|
||||
&self.three_way_text.theirs,
|
||||
&self.three_way_line_starts.theirs,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn three_way_line_text(&self, side: ThreeWayColumn, line_ix: usize) -> Option<&str> {
|
||||
match side {
|
||||
ThreeWayColumn::Base => indexed_line_text(
|
||||
&self.three_way_text.base,
|
||||
&self.three_way_line_starts.base,
|
||||
line_ix,
|
||||
),
|
||||
ThreeWayColumn::Ours => indexed_line_text(
|
||||
&self.three_way_text.ours,
|
||||
&self.three_way_line_starts.ours,
|
||||
line_ix,
|
||||
),
|
||||
ThreeWayColumn::Theirs => indexed_line_text(
|
||||
&self.three_way_text.theirs,
|
||||
&self.three_way_line_starts.theirs,
|
||||
line_ix,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn three_way_has_line(&self, side: ThreeWayColumn, line_ix: usize) -> bool {
|
||||
self.three_way_line_text(side, line_ix).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conflict_resolver_ui_state_tests {
|
||||
use super::{ConflictResolverUiState, ThreeWaySides};
|
||||
|
|
@ -445,9 +543,12 @@ mod conflict_resolver_ui_state_tests {
|
|||
fn default_groups_three_way_side_fields() {
|
||||
let state = ConflictResolverUiState::default();
|
||||
|
||||
assert!(state.three_way_lines.base.is_empty());
|
||||
assert!(state.three_way_lines.ours.is_empty());
|
||||
assert!(state.three_way_lines.theirs.is_empty());
|
||||
assert!(state.three_way_text.base.is_empty());
|
||||
assert!(state.three_way_text.ours.is_empty());
|
||||
assert!(state.three_way_text.theirs.is_empty());
|
||||
assert!(state.three_way_line_starts.base.is_empty());
|
||||
assert!(state.three_way_line_starts.ours.is_empty());
|
||||
assert!(state.three_way_line_starts.theirs.is_empty());
|
||||
|
||||
assert!(state.three_way_line_conflict_map.base.is_empty());
|
||||
assert!(state.three_way_line_conflict_map.ours.is_empty());
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ impl MainPaneView {
|
|||
};
|
||||
if total_len == 0 {
|
||||
components::empty_state(theme, "Diff", "Empty file.").into_any_element()
|
||||
} else if self.diff_visible_indices.is_empty() {
|
||||
} else if self.diff_visible_len() == 0 {
|
||||
components::empty_state(theme, "Diff", "Nothing to render.")
|
||||
.into_any_element()
|
||||
} else {
|
||||
|
|
@ -306,7 +306,7 @@ impl MainPaneView {
|
|||
DiffViewMode::Inline => {
|
||||
let list = uniform_list(
|
||||
"diff",
|
||||
self.diff_visible_indices.len(),
|
||||
self.diff_visible_len(),
|
||||
cx.processor(Self::render_diff_rows),
|
||||
)
|
||||
.h_full()
|
||||
|
|
@ -345,7 +345,7 @@ impl MainPaneView {
|
|||
self.sync_diff_split_vertical_scroll();
|
||||
let right_scroll_handle =
|
||||
self.diff_split_right_scroll.0.borrow().base_handle.clone();
|
||||
let count = self.diff_visible_indices.len();
|
||||
let count = self.diff_visible_len();
|
||||
let left = uniform_list(
|
||||
"diff_split_left",
|
||||
count,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ impl MainPaneView {
|
|||
fn toggle_show_whitespace(&mut self) {
|
||||
self.show_whitespace = !self.show_whitespace;
|
||||
// Clear styled text caches so they rebuild with new whitespace setting.
|
||||
self.diff_text_segments_cache.clear();
|
||||
self.clear_diff_text_style_caches();
|
||||
self.clear_conflict_diff_style_caches();
|
||||
self.conflict_three_way_segments_cache.clear();
|
||||
}
|
||||
|
|
@ -20,13 +20,9 @@ impl MainPaneView {
|
|||
let untracked_preview_path = self.untracked_worktree_preview_path();
|
||||
let added_preview_path = self.added_file_preview_abs_path();
|
||||
let deleted_preview_path = self.deleted_file_preview_abs_path();
|
||||
let untracked_directory_notice = self.untracked_directory_notice();
|
||||
|
||||
let preview_path = untracked_preview_path
|
||||
.as_deref()
|
||||
.or(added_preview_path.as_deref())
|
||||
.or(deleted_preview_path.as_deref());
|
||||
let is_file_preview = preview_path
|
||||
.is_some_and(|p| !super::super::should_bypass_text_file_preview_for_path(p));
|
||||
let is_file_preview = self.is_file_preview_active() && untracked_directory_notice.is_none();
|
||||
|
||||
if is_file_preview {
|
||||
if let Some(path) = untracked_preview_path.clone() {
|
||||
|
|
@ -34,8 +30,17 @@ impl MainPaneView {
|
|||
} else if let Some(path) = added_preview_path.clone().or(deleted_preview_path.clone()) {
|
||||
self.ensure_preview_loading(path);
|
||||
}
|
||||
} else if untracked_directory_notice.is_some()
|
||||
&& matches!(self.worktree_preview, Loadable::Loading)
|
||||
{
|
||||
self.worktree_preview_path = None;
|
||||
self.worktree_preview = Loadable::NotLoaded;
|
||||
self.worktree_preview_segments_cache_path = None;
|
||||
self.worktree_preview_segments_cache.clear();
|
||||
self.diff_horizontal_min_width = px(0.0);
|
||||
}
|
||||
let wants_file_diff = !is_file_preview
|
||||
&& !self.is_worktree_target_directory()
|
||||
&& self
|
||||
.active_repo()
|
||||
.is_some_and(|r| Self::is_file_diff_target(r.diff_state.diff_target.as_ref()));
|
||||
|
|
@ -330,7 +335,7 @@ impl MainPaneView {
|
|||
.selected_bg(view_toggle_selected_bg)
|
||||
.on_click(theme, cx, |this, _e, _w, cx| {
|
||||
this.diff_view = DiffViewMode::Inline;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_style_caches();
|
||||
if this.diff_search_active
|
||||
&& !this.diff_search_query.as_ref().trim().is_empty()
|
||||
{
|
||||
|
|
@ -358,7 +363,7 @@ impl MainPaneView {
|
|||
.selected_bg(view_toggle_selected_bg)
|
||||
.on_click(theme, cx, |this, _e, _w, cx| {
|
||||
this.diff_view = DiffViewMode::Split;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_style_caches();
|
||||
if this.diff_search_active
|
||||
&& !this.diff_search_query.as_ref().trim().is_empty()
|
||||
{
|
||||
|
|
@ -531,7 +536,7 @@ impl MainPaneView {
|
|||
this.diff_search_active = false;
|
||||
this.diff_search_matches.clear();
|
||||
this.diff_search_match_ix = None;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_query_overlay_cache();
|
||||
this.worktree_preview_segments_cache_path = None;
|
||||
this.worktree_preview_segments_cache.clear();
|
||||
this.clear_conflict_diff_query_overlay_caches();
|
||||
|
|
@ -558,9 +563,11 @@ impl MainPaneView {
|
|||
)
|
||||
.child(controls);
|
||||
|
||||
let body: AnyElement = if is_file_preview {
|
||||
let body: AnyElement = if let Some(message) = untracked_directory_notice {
|
||||
components::empty_state(theme, "Directory", message).into_any_element()
|
||||
} else if is_file_preview {
|
||||
if added_preview_path.is_some() || deleted_preview_path.is_some() {
|
||||
self.try_populate_worktree_preview_from_diff_file();
|
||||
self.try_populate_worktree_preview_from_diff_file(cx);
|
||||
}
|
||||
match &self.worktree_preview {
|
||||
Loadable::NotLoaded | Loadable::Loading => {
|
||||
|
|
@ -1575,33 +1582,6 @@ impl MainPaneView {
|
|||
.child(start_controls);
|
||||
let autosolve_summary =
|
||||
self.conflict_resolver.last_autosolve_summary.clone();
|
||||
let merge_conflict_markers = self
|
||||
.conflict_resolver
|
||||
.resolved_output_conflict_markers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(line_ix, marker)| {
|
||||
marker
|
||||
.as_ref()
|
||||
.copied()
|
||||
.map(|m| (line_ix, m))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let has_merge_conflict_marker =
|
||||
!merge_conflict_markers.is_empty();
|
||||
let unresolved_merge_conflict_row_bg = {
|
||||
let mut color = theme.colors.danger;
|
||||
let t = if theme.is_dark { 0.72 } else { 0.82 };
|
||||
color.r = color.r + (theme.colors.surface_bg_elevated.r - color.r) * t;
|
||||
color.g = color.g + (theme.colors.surface_bg_elevated.g - color.g) * t;
|
||||
color.b = color.b + (theme.colors.surface_bg_elevated.b - color.b) * t;
|
||||
color.a = if theme.is_dark { 0.72 } else { 0.58 };
|
||||
color
|
||||
};
|
||||
let resolved_merge_conflict_row_bg = with_alpha(
|
||||
theme.colors.surface_bg_elevated,
|
||||
if theme.is_dark { 0.54 } else { 0.74 },
|
||||
);
|
||||
|
||||
// Vertical resize handle between merge inputs and resolved output
|
||||
let vsplit_ratio = self.conflict_resolver_vsplit_ratio;
|
||||
|
|
@ -1737,7 +1717,7 @@ impl MainPaneView {
|
|||
.base_handle
|
||||
.clone();
|
||||
let outline_len =
|
||||
self.conflict_resolved_preview_lines.len();
|
||||
self.conflict_resolved_preview_line_count;
|
||||
let outline_list = uniform_list(
|
||||
"conflict_resolved_preview_gutter_list",
|
||||
outline_len,
|
||||
|
|
@ -1750,101 +1730,6 @@ impl MainPaneView {
|
|||
.track_scroll(
|
||||
self.conflict_resolved_preview_scroll.clone(),
|
||||
);
|
||||
let merge_conflict_overlay =
|
||||
has_merge_conflict_marker.then(|| {
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(0.0))
|
||||
.left(px(0.0))
|
||||
.right(px(0.0))
|
||||
.children(
|
||||
merge_conflict_markers
|
||||
.iter()
|
||||
.copied()
|
||||
.map(
|
||||
|(line_ix, marker)| {
|
||||
let conflict_ix =
|
||||
marker.conflict_ix;
|
||||
let top = px(
|
||||
(line_ix as f32)
|
||||
* 20.0,
|
||||
);
|
||||
let has_base = self
|
||||
.conflict_resolver_has_base_for_conflict_ix(
|
||||
conflict_ix,
|
||||
);
|
||||
let is_three_way = self
|
||||
.conflict_resolver
|
||||
.view_mode
|
||||
== ConflictResolverViewMode::ThreeWay;
|
||||
let selected_choices = self
|
||||
.conflict_resolver_selected_choices_for_conflict_ix(
|
||||
conflict_ix,
|
||||
);
|
||||
let context_menu_invoker: SharedString = format!(
|
||||
"resolver_output_merge_conflict_row_{}_{}",
|
||||
conflict_ix, line_ix
|
||||
)
|
||||
.into();
|
||||
let row_bg = if marker.unresolved {
|
||||
unresolved_merge_conflict_row_bg
|
||||
} else {
|
||||
resolved_merge_conflict_row_bg
|
||||
};
|
||||
div()
|
||||
.absolute()
|
||||
.left(px(0.0))
|
||||
.right(px(0.0))
|
||||
.top(top)
|
||||
.h(px(20.0))
|
||||
.bg(row_bg)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(
|
||||
move |this, e: &MouseDownEvent, window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.open_conflict_resolver_chunk_context_menu(
|
||||
context_menu_invoker.clone(),
|
||||
conflict_ix,
|
||||
has_base,
|
||||
is_three_way,
|
||||
selected_choices.clone(),
|
||||
Some(line_ix),
|
||||
e.position,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.child({
|
||||
let label = div()
|
||||
.flex()
|
||||
.w_full()
|
||||
.h_full()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.text_size(px(10.0))
|
||||
.font_family("monospace")
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.text_color(with_alpha(
|
||||
theme.colors.text,
|
||||
0.0,
|
||||
))
|
||||
.hover(move |s| {
|
||||
s.text_color(theme.colors.text)
|
||||
});
|
||||
if marker.is_start {
|
||||
label.child("<Merge conflict>")
|
||||
} else {
|
||||
label
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
},
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
div()
|
||||
.id("conflict_resolver_output_body")
|
||||
|
|
@ -1906,40 +1791,26 @@ impl MainPaneView {
|
|||
.min_h(px(0.0))
|
||||
.pl_2()
|
||||
.child(
|
||||
div()
|
||||
.id(
|
||||
"conflict_resolver_output_scroll",
|
||||
)
|
||||
.relative()
|
||||
.h_full()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(
|
||||
&output_scroll_handle,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id(
|
||||
"conflict_resolver_output_editor_content",
|
||||
)
|
||||
.relative()
|
||||
.min_w_full()
|
||||
.child(
|
||||
div()
|
||||
.h_full()
|
||||
.child(
|
||||
self.conflict_resolver_input
|
||||
.clone(),
|
||||
),
|
||||
)
|
||||
.when_some(
|
||||
merge_conflict_overlay,
|
||||
|d, overlay| d.child(overlay),
|
||||
),
|
||||
uniform_list(
|
||||
"conflict_resolved_output_list",
|
||||
outline_len,
|
||||
cx.processor(
|
||||
Self::render_conflict_resolved_output_rows,
|
||||
),
|
||||
)
|
||||
.h_full()
|
||||
.min_h(px(0.0))
|
||||
.track_scroll(
|
||||
self.conflict_resolved_preview_scroll.clone(),
|
||||
)
|
||||
.with_horizontal_sizing_behavior(
|
||||
gpui::ListHorizontalSizingBehavior::Unconstrained,
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
.child(
|
||||
components::Scrollbar::new(
|
||||
"conflict_resolver_output_scrollbar",
|
||||
|
|
@ -2146,17 +2017,17 @@ impl MainPaneView {
|
|||
if self.diff_cache_repo_id != Some(repo.id)
|
||||
|| self.diff_cache_rev != repo.diff_state.diff_rev
|
||||
|| self.diff_cache_target != repo.diff_state.diff_target
|
||||
|| self.diff_cache.len() != diff.lines.len()
|
||||
|| self.patch_diff_row_len() != diff.lines.len()
|
||||
{
|
||||
self.rebuild_diff_cache(cx);
|
||||
}
|
||||
|
||||
self.ensure_diff_visible_indices();
|
||||
self.maybe_autoscroll_diff_to_first_change();
|
||||
if self.diff_cache.is_empty() {
|
||||
if self.patch_diff_row_len() == 0 {
|
||||
components::empty_state(theme, "Diff", "No differences.")
|
||||
.into_any_element()
|
||||
} else if self.diff_visible_indices.is_empty() {
|
||||
} else if self.diff_visible_len() == 0 {
|
||||
components::empty_state(theme, "Diff", "Nothing to render.")
|
||||
.into_any_element()
|
||||
} else {
|
||||
|
|
@ -2167,7 +2038,7 @@ impl MainPaneView {
|
|||
DiffViewMode::Inline => {
|
||||
let list = uniform_list(
|
||||
"diff",
|
||||
self.diff_visible_indices.len(),
|
||||
self.diff_visible_len(),
|
||||
cx.processor(Self::render_diff_rows),
|
||||
)
|
||||
.h_full()
|
||||
|
|
@ -2210,7 +2081,7 @@ impl MainPaneView {
|
|||
.borrow()
|
||||
.base_handle
|
||||
.clone();
|
||||
let count = self.diff_visible_indices.len();
|
||||
let count = self.diff_visible_len();
|
||||
let left = uniform_list(
|
||||
"diff_split_left",
|
||||
count,
|
||||
|
|
@ -2487,7 +2358,7 @@ impl MainPaneView {
|
|||
this.diff_search_active = false;
|
||||
this.diff_search_matches.clear();
|
||||
this.diff_search_match_ix = None;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_query_overlay_cache();
|
||||
this.worktree_preview_segments_cache_path = None;
|
||||
this.worktree_preview_segments_cache.clear();
|
||||
this.clear_conflict_diff_query_overlay_caches();
|
||||
|
|
@ -2508,7 +2379,7 @@ impl MainPaneView {
|
|||
&& key == "f"
|
||||
{
|
||||
this.diff_search_active = true;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_query_overlay_cache();
|
||||
this.worktree_preview_segments_cache_path = None;
|
||||
this.worktree_preview_segments_cache.clear();
|
||||
this.clear_conflict_diff_query_overlay_caches();
|
||||
|
|
@ -2639,9 +2510,7 @@ impl MainPaneView {
|
|||
.read(cx)
|
||||
.focus_handle()
|
||||
.is_focused(window);
|
||||
let is_file_preview = this.untracked_worktree_preview_path().is_some()
|
||||
|| this.added_file_preview_abs_path().is_some()
|
||||
|| this.deleted_file_preview_abs_path().is_some();
|
||||
let is_file_preview = this.is_file_preview_active();
|
||||
if is_file_preview {
|
||||
if !handled
|
||||
&& !copy_target_is_focused
|
||||
|
|
@ -2701,7 +2570,7 @@ impl MainPaneView {
|
|||
this.conflict_resolver_set_mode(ConflictDiffMode::Inline, cx);
|
||||
} else {
|
||||
this.diff_view = DiffViewMode::Inline;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_style_caches();
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
|
|
@ -2710,19 +2579,18 @@ impl MainPaneView {
|
|||
this.conflict_resolver_set_mode(ConflictDiffMode::Split, cx);
|
||||
} else {
|
||||
this.diff_view = DiffViewMode::Split;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_style_caches();
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
"h" => {
|
||||
let is_file_preview = this.untracked_worktree_preview_path().is_some()
|
||||
|| this.added_file_preview_abs_path().is_some()
|
||||
|| this.deleted_file_preview_abs_path().is_some();
|
||||
if !is_file_preview
|
||||
&& !this.active_repo().is_some_and(|r| {
|
||||
let is_file_preview = this.is_file_preview_active();
|
||||
let wants_file_diff = !is_file_preview
|
||||
&& !this.is_worktree_target_directory()
|
||||
&& this.active_repo().is_some_and(|r| {
|
||||
Self::is_file_diff_target(r.diff_state.diff_target.as_ref())
|
||||
})
|
||||
{
|
||||
});
|
||||
if !is_file_preview && !wants_file_diff {
|
||||
this.open_popover_at_cursor(PopoverKind::DiffHunks, window, cx);
|
||||
handled = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context<PopoverHost>)
|
|||
let close = cx.listener(|this, _e: &ClickEvent, _w, cx| this.close_popover(cx));
|
||||
|
||||
let pane = this.main_pane.read(cx);
|
||||
let mut items: Vec<SharedString> = Vec::with_capacity(pane.diff_visible_indices.len());
|
||||
let mut targets: Vec<usize> = Vec::with_capacity(pane.diff_visible_indices.len());
|
||||
let visible_len = pane.diff_visible_len();
|
||||
let mut items: Vec<SharedString> = Vec::with_capacity(visible_len);
|
||||
let mut targets: Vec<usize> = Vec::with_capacity(visible_len);
|
||||
let mut current_file: Option<String> = None;
|
||||
|
||||
if !pane.is_file_diff_view_active() {
|
||||
for (visible_ix, &ix) in pane.diff_visible_indices.iter().enumerate() {
|
||||
for visible_ix in 0..visible_len {
|
||||
let Some(ix) = pane.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
continue;
|
||||
};
|
||||
let (src_ix, click_kind) = match pane.diff_view {
|
||||
DiffViewMode::Inline => {
|
||||
let click_kind = pane
|
||||
|
|
@ -21,17 +25,17 @@ pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context<PopoverHost>)
|
|||
(ix, click_kind)
|
||||
}
|
||||
DiffViewMode::Split => {
|
||||
let Some(row) = pane.diff_split_cache.get(ix) else {
|
||||
let Some(row) = pane.patch_diff_split_row(ix) else {
|
||||
continue;
|
||||
};
|
||||
let PatchSplitRow::Raw { src_ix, click_kind } = row else {
|
||||
continue;
|
||||
};
|
||||
(*src_ix, *click_kind)
|
||||
(src_ix, click_kind)
|
||||
}
|
||||
};
|
||||
|
||||
let Some(line) = pane.diff_cache.get(src_ix) else {
|
||||
let Some(line) = pane.patch_diff_row(src_ix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -339,6 +339,8 @@ fn file_preview_context_menu_matches_diff_editor_actions(cx: &mut gpui::TestAppC
|
|||
std::process::id()
|
||||
));
|
||||
let path = std::path::PathBuf::from("added.txt");
|
||||
std::fs::create_dir_all(&workdir).expect("create preview test workdir");
|
||||
std::fs::write(workdir.join(&path), "alpha\nbeta\n").expect("write preview test file");
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
|
|
@ -384,7 +386,7 @@ fn file_preview_context_menu_matches_diff_editor_actions(cx: &mut gpui::TestAppC
|
|||
cx.update(|window, app| {
|
||||
let main_pane = view.read(app).main_pane.clone();
|
||||
main_pane.update(app, |pane, cx| {
|
||||
pane.try_populate_worktree_preview_from_diff_file();
|
||||
pane.try_populate_worktree_preview_from_diff_file(cx);
|
||||
pane.open_diff_editor_context_menu(
|
||||
1,
|
||||
DiffTextRegion::Inline,
|
||||
|
|
|
|||
|
|
@ -137,6 +137,12 @@ fn assert_file_preview_ctrl_a_ctrl_c_copies_all(
|
|||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
// Create the file on disk so is_file_preview_active() can detect it.
|
||||
let _ = std::fs::create_dir_all(&workdir);
|
||||
std::fs::write(workdir.join(&file_rel), lines.join("\n")).expect("write preview fixture file");
|
||||
|
||||
// Push state through the model first; the observer will clear stale
|
||||
// worktree_preview on diff-target change.
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
|
|
@ -170,7 +176,13 @@ fn assert_file_preview_ctrl_a_ctrl_c_copies_all(
|
|||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Set preview data in a separate update so it runs after the observer
|
||||
// has cleared the stale preview state.
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let workdir = workdir.clone();
|
||||
let file_rel = file_rel.clone();
|
||||
let lines = Arc::clone(&lines);
|
||||
|
|
@ -198,6 +210,8 @@ fn assert_file_preview_ctrl_a_ctrl_c_copies_all(
|
|||
cx.read_from_clipboard().and_then(|item| item.text()),
|
||||
Some(expected.into())
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
@ -218,6 +232,12 @@ fn file_preview_renders_scrollable_syntax_highlighted_rows(cx: &mut gpui::TestAp
|
|||
.collect(),
|
||||
);
|
||||
|
||||
// Create the file on disk so is_file_preview_active() can detect it.
|
||||
let _ = std::fs::create_dir_all(&workdir);
|
||||
std::fs::write(workdir.join(&file_rel), lines.join("\n")).expect("write preview fixture file");
|
||||
|
||||
// Push state through the model first; the observer will clear stale
|
||||
// worktree_preview on diff-target change.
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
|
|
@ -251,7 +271,13 @@ fn file_preview_renders_scrollable_syntax_highlighted_rows(cx: &mut gpui::TestAp
|
|||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Set preview data in a separate update so it runs after the observer
|
||||
// has cleared the stale preview state.
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let workdir = workdir.clone();
|
||||
let file_rel = file_rel.clone();
|
||||
let lines = Arc::clone(&lines);
|
||||
|
|
@ -297,6 +323,8 @@ fn file_preview_renders_scrollable_syntax_highlighted_rows(cx: &mut gpui::TestAp
|
|||
"expected syntax highlighting highlights for preview row"
|
||||
);
|
||||
});
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
@ -379,6 +407,137 @@ fn patch_view_applies_syntax_highlighting_to_context_lines(cx: &mut gpui::TestAp
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn patch_diff_search_query_keeps_stable_style_cache_entries(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(22);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_patch_search",
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let target = gitcomet_core::domain::DiffTarget::Commit {
|
||||
commit_id: gitcomet_core::domain::CommitId("feedface".to_string()),
|
||||
path: None,
|
||||
};
|
||||
|
||||
let diff = gitcomet_core::domain::Diff {
|
||||
target: target.clone(),
|
||||
lines: vec![
|
||||
gitcomet_core::domain::DiffLine {
|
||||
kind: gitcomet_core::domain::DiffLineKind::Header,
|
||||
text: "diff --git a/foo.rs b/foo.rs".into(),
|
||||
},
|
||||
gitcomet_core::domain::DiffLine {
|
||||
kind: gitcomet_core::domain::DiffLineKind::Hunk,
|
||||
text: "@@ -1,1 +1,1 @@".into(),
|
||||
},
|
||||
gitcomet_core::domain::DiffLine {
|
||||
kind: gitcomet_core::domain::DiffLineKind::Context,
|
||||
text: " fn main() { let x = 1; }".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus::default().into(),
|
||||
);
|
||||
repo.diff_state.diff_target = Some(target);
|
||||
repo.diff_state.diff_rev = 1;
|
||||
repo.diff_state.diff = gitcomet_state::model::Loadable::Ready(diff.into());
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|window, app| {
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
|
||||
let mut stable_highlights_hash_before = 0u64;
|
||||
let mut stable_text_hash_before = 0u64;
|
||||
cx.update(|_window, app| {
|
||||
let main_pane = view.read(app).main_pane.clone();
|
||||
let pane = main_pane.read(app);
|
||||
let stable = pane
|
||||
.diff_text_segments_cache
|
||||
.get(2)
|
||||
.and_then(|entry| entry.as_ref())
|
||||
.expect("expected stable cache entry for context row before search");
|
||||
assert!(
|
||||
pane.diff_text_query_segments_cache.is_empty(),
|
||||
"query overlay cache should start empty"
|
||||
);
|
||||
stable_highlights_hash_before = stable.highlights_hash;
|
||||
stable_text_hash_before = stable.text_hash;
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let main_pane = view.read(app).main_pane.clone();
|
||||
main_pane.update(app, |pane, cx| {
|
||||
pane.diff_search_active = true;
|
||||
pane.diff_search_input.update(cx, |input, cx| {
|
||||
input.set_text("main", cx);
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|window, app| {
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let main_pane = view.read(app).main_pane.clone();
|
||||
let pane = main_pane.read(app);
|
||||
|
||||
let stable_after = pane
|
||||
.diff_text_segments_cache
|
||||
.get(2)
|
||||
.and_then(|entry| entry.as_ref())
|
||||
.expect("expected stable cache entry for context row after search query update");
|
||||
assert_eq!(
|
||||
stable_after.highlights_hash, stable_highlights_hash_before,
|
||||
"search query updates should not rewrite stable style highlights"
|
||||
);
|
||||
assert_eq!(
|
||||
stable_after.text_hash, stable_text_hash_before,
|
||||
"search query updates should not rewrite stable styled text"
|
||||
);
|
||||
|
||||
assert_eq!(pane.diff_text_query_cache_query.as_ref(), "main");
|
||||
let query_overlay = pane
|
||||
.diff_text_query_segments_cache
|
||||
.get(2)
|
||||
.and_then(|entry| entry.as_ref())
|
||||
.expect("expected query overlay cache entry for searched context row");
|
||||
assert_ne!(
|
||||
query_overlay.highlights_hash, stable_after.highlights_hash,
|
||||
"query overlay should layer match highlighting on top of stable highlights"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn staged_deleted_file_preview_uses_old_contents(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
|
|
@ -438,7 +597,7 @@ fn staged_deleted_file_preview_uses_old_contents(cx: &mut gpui::TestAppContext)
|
|||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
this.main_pane.update(cx, |pane, cx| {
|
||||
pane.try_populate_worktree_preview_from_diff_file();
|
||||
pane.try_populate_worktree_preview_from_diff_file(cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
|
|
@ -457,6 +616,688 @@ fn staged_deleted_file_preview_uses_old_contents(cx: &mut gpui::TestAppContext)
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn unstaged_deleted_gitlink_preview_does_not_stay_loading(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(44);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_unstaged_gitlink",
|
||||
std::process::id()
|
||||
));
|
||||
let file_rel = std::path::PathBuf::from("chess3");
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(&workdir).expect("create workdir");
|
||||
|
||||
let target = gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Unstaged,
|
||||
};
|
||||
let unified = format!(
|
||||
"diff --git a/{0} b/{0}\nindex 1234567..0000000 160000\n--- a/{0}\n+++ /dev/null\n@@ -1 +0,0 @@\n-Subproject commit c35be02cd52b18c7b2894dc570825b43c94130ed\n",
|
||||
file_rel.display()
|
||||
);
|
||||
let diff = gitcomet_core::domain::Diff::from_unified(target.clone(), &unified);
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus {
|
||||
staged: vec![],
|
||||
unstaged: vec![gitcomet_core::domain::FileStatus {
|
||||
path: file_rel.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Deleted,
|
||||
conflict: None,
|
||||
}],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
repo.diff_state.diff_target = Some(target.clone());
|
||||
repo.diff_state.diff = gitcomet_state::model::Loadable::Ready(Arc::new(diff));
|
||||
repo.diff_state.diff_file = gitcomet_state::model::Loadable::Ready(None);
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|window, app| {
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
assert!(
|
||||
!matches!(
|
||||
pane.worktree_preview,
|
||||
gitcomet_state::model::Loadable::Loading
|
||||
),
|
||||
"unstaged gitlink-like deleted target should not remain stuck in File Loading"
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup unstaged gitlink fixture");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn unstaged_modified_gitlink_target_uses_unified_diff_mode(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(45);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_unstaged_gitlink_mod",
|
||||
std::process::id()
|
||||
));
|
||||
let file_rel = std::path::PathBuf::from("chess3");
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(workdir.join(&file_rel)).expect("create gitlink-like directory");
|
||||
|
||||
let target = gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Unstaged,
|
||||
};
|
||||
let unified = format!(
|
||||
"diff --git a/{0} b/{0}\nindex 1234567..89abcde 160000\n--- a/{0}\n+++ b/{0}\n@@ -1 +1 @@\n-Subproject commit 1234567890123456789012345678901234567890\n+Subproject commit 89abcdef0123456789abcdef0123456789abcdef\n",
|
||||
file_rel.display()
|
||||
);
|
||||
let diff = gitcomet_core::domain::Diff::from_unified(target.clone(), &unified);
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus {
|
||||
staged: vec![gitcomet_core::domain::FileStatus {
|
||||
path: file_rel.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Added,
|
||||
conflict: None,
|
||||
}],
|
||||
unstaged: vec![gitcomet_core::domain::FileStatus {
|
||||
path: file_rel.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Modified,
|
||||
conflict: None,
|
||||
}],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
repo.diff_state.diff_target = Some(target);
|
||||
repo.diff_state.diff = gitcomet_state::model::Loadable::Ready(Arc::new(diff));
|
||||
repo.diff_state.diff_file = gitcomet_state::model::Loadable::Ready(None);
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|window, app| {
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
assert!(
|
||||
pane.is_worktree_target_directory(),
|
||||
"gitlink-like target should be treated as directory-backed for unified diff mode"
|
||||
);
|
||||
assert!(
|
||||
!pane.is_file_preview_active(),
|
||||
"unstaged modified gitlink target should bypass file preview mode"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
pane.worktree_preview,
|
||||
gitcomet_state::model::Loadable::Loading
|
||||
),
|
||||
"unstaged modified gitlink target should not show stuck File Loading state"
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup unstaged gitlink modified fixture");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn ensure_preview_loading_does_not_reenter_loading_from_error_for_same_path(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_preview_loading_error",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&temp);
|
||||
std::fs::create_dir_all(&temp).expect("create temp directory");
|
||||
let path_a = temp.join("a.txt");
|
||||
let path_b = temp.join("b.txt");
|
||||
std::fs::write(&path_a, "a\n").expect("write a.txt");
|
||||
std::fs::write(&path_b, "b\n").expect("write b.txt");
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
this.main_pane.update(cx, |pane, _cx| {
|
||||
pane.worktree_preview_path = Some(path_a.clone());
|
||||
pane.worktree_preview = gitcomet_state::model::Loadable::Error("boom".into());
|
||||
|
||||
// Same path: keep showing the existing error, do not bounce back to Loading.
|
||||
pane.ensure_preview_loading(path_a.clone());
|
||||
assert!(
|
||||
matches!(
|
||||
pane.worktree_preview,
|
||||
gitcomet_state::model::Loadable::Error(_)
|
||||
),
|
||||
"same-path retry should not reset Error to Loading"
|
||||
);
|
||||
|
||||
// Different path: loading the newly selected file is expected.
|
||||
pane.ensure_preview_loading(path_b.clone());
|
||||
assert_eq!(pane.worktree_preview_path, Some(path_b.clone()));
|
||||
assert!(
|
||||
matches!(
|
||||
pane.worktree_preview,
|
||||
gitcomet_state::model::Loadable::Loading
|
||||
),
|
||||
"new path selection should enter Loading"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&temp).expect("cleanup temp directory");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn switching_diff_target_clears_stale_worktree_preview_loading(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(36);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_switch_preview_target",
|
||||
std::process::id()
|
||||
));
|
||||
let file_a = std::path::PathBuf::from("a.txt");
|
||||
let file_b = std::path::PathBuf::from("b.txt");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(&workdir).expect("create workdir");
|
||||
|
||||
let make_state = |target_path: std::path::PathBuf, diff_state_rev: u64| {
|
||||
Arc::new(AppState {
|
||||
repos: vec![{
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus {
|
||||
staged: vec![],
|
||||
unstaged: vec![
|
||||
gitcomet_core::domain::FileStatus {
|
||||
path: file_a.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Untracked,
|
||||
conflict: None,
|
||||
},
|
||||
gitcomet_core::domain::FileStatus {
|
||||
path: file_b.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Untracked,
|
||||
conflict: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
repo.diff_state.diff_target =
|
||||
Some(gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: target_path,
|
||||
area: gitcomet_core::domain::DiffArea::Unstaged,
|
||||
});
|
||||
repo.diff_state.diff_state_rev = diff_state_rev;
|
||||
repo
|
||||
}],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let first = make_state(file_a.clone(), 1);
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(first, cx);
|
||||
});
|
||||
this.main_pane.update(cx, |pane, _cx| {
|
||||
pane.worktree_preview_path = Some(workdir.join(&file_a));
|
||||
pane.worktree_preview = gitcomet_state::model::Loadable::Loading;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let second = make_state(file_b.clone(), 2);
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(second, cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
let stale_path = workdir.join(&file_a);
|
||||
let is_stale_loading =
|
||||
matches!(pane.worktree_preview, gitcomet_state::model::Loadable::Loading)
|
||||
&& pane.worktree_preview_path.as_ref() == Some(&stale_path);
|
||||
assert!(
|
||||
!is_stale_loading,
|
||||
"switching selected file should not keep stale Loading on previous path; state={:?} path={:?}",
|
||||
pane.worktree_preview,
|
||||
pane.worktree_preview_path
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup workdir");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn staged_directory_target_uses_unified_diff_mode(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(34);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_staged_dir",
|
||||
std::process::id()
|
||||
));
|
||||
let file_rel = std::path::PathBuf::from("subproject");
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(workdir.join(&file_rel)).expect("create staged directory path");
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus {
|
||||
staged: vec![gitcomet_core::domain::FileStatus {
|
||||
path: file_rel.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Added,
|
||||
conflict: None,
|
||||
}],
|
||||
unstaged: vec![],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
repo.diff_state.diff_target = Some(gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Staged,
|
||||
});
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
assert!(
|
||||
pane.is_worktree_target_directory(),
|
||||
"expected staged directory target detection for gitlink-like entries"
|
||||
);
|
||||
assert!(
|
||||
!pane.is_file_preview_active(),
|
||||
"directory targets should avoid file preview mode to show unified subproject diffs"
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup staged directory fixture");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn staged_added_missing_target_uses_unified_diff_mode(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(43);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_staged_added_missing",
|
||||
std::process::id()
|
||||
));
|
||||
let file_rel = std::path::PathBuf::from("subproject");
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(&workdir).expect("create workdir");
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus {
|
||||
staged: vec![gitcomet_core::domain::FileStatus {
|
||||
path: file_rel.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Added,
|
||||
conflict: None,
|
||||
}],
|
||||
unstaged: vec![],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
repo.diff_state.diff_target = Some(gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Staged,
|
||||
});
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
assert!(
|
||||
!pane.is_file_preview_active(),
|
||||
"staged Added targets that are not real files should bypass file preview to avoid stuck loading"
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup staged-added-missing fixture");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn untracked_directory_target_uses_unified_diff_mode(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(35);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_unstaged_dir",
|
||||
std::process::id()
|
||||
));
|
||||
let file_rel = std::path::PathBuf::from("subproject");
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(workdir.join(&file_rel)).expect("create untracked directory path");
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus {
|
||||
staged: vec![],
|
||||
unstaged: vec![gitcomet_core::domain::FileStatus {
|
||||
path: file_rel.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Untracked,
|
||||
conflict: None,
|
||||
}],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
repo.diff_state.diff_target = Some(gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Unstaged,
|
||||
});
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
assert!(
|
||||
pane.is_worktree_target_directory(),
|
||||
"expected untracked directory target detection for gitlink-like entries"
|
||||
);
|
||||
assert!(
|
||||
!pane.is_file_preview_active(),
|
||||
"untracked directory targets should avoid file preview loading mode"
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup untracked directory fixture");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn untracked_directory_target_clears_stale_file_loading_state(cx: &mut gpui::TestAppContext) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(46);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_unstaged_dir_stale_loading",
|
||||
std::process::id()
|
||||
));
|
||||
let file_rel = std::path::PathBuf::from("chess3");
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(workdir.join(&file_rel)).expect("create untracked directory path");
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
repo.status = gitcomet_state::model::Loadable::Ready(
|
||||
gitcomet_core::domain::RepoStatus {
|
||||
staged: vec![],
|
||||
unstaged: vec![gitcomet_core::domain::FileStatus {
|
||||
path: file_rel.clone(),
|
||||
kind: gitcomet_core::domain::FileStatusKind::Untracked,
|
||||
conflict: None,
|
||||
}],
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
repo.diff_state.diff_target = Some(gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Unstaged,
|
||||
});
|
||||
repo.diff_state.diff = gitcomet_state::model::Loadable::Ready(Arc::new(
|
||||
gitcomet_core::domain::Diff::from_unified(
|
||||
gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Unstaged,
|
||||
},
|
||||
"",
|
||||
),
|
||||
));
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
|
||||
this.main_pane.update(cx, |pane, _cx| {
|
||||
pane.worktree_preview_path = Some(workdir.join(&file_rel));
|
||||
pane.worktree_preview = gitcomet_state::model::Loadable::Loading;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|window, app| {
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
assert!(
|
||||
pane.untracked_directory_notice().is_some(),
|
||||
"expected untracked directory selection to expose a directory-specific notice"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
pane.worktree_preview,
|
||||
gitcomet_state::model::Loadable::Loading
|
||||
),
|
||||
"untracked directory target should not stay stuck in File Loading"
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup stale-loading untracked directory fixture");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn directory_target_with_loading_status_clears_stale_file_loading_state(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
let (store, events) = AppStore::new(Arc::new(TestBackend));
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
super::super::GitCometView::new(store, events, None, window, cx)
|
||||
});
|
||||
|
||||
let repo_id = gitcomet_state::model::RepoId(47);
|
||||
let workdir = std::env::temp_dir().join(format!(
|
||||
"gitcomet_ui_test_{}_directory_loading_status",
|
||||
std::process::id()
|
||||
));
|
||||
let file_rel = std::path::PathBuf::from("chess3");
|
||||
let _ = std::fs::remove_dir_all(&workdir);
|
||||
std::fs::create_dir_all(workdir.join(&file_rel)).expect("create directory target path");
|
||||
|
||||
cx.update(|_window, app| {
|
||||
view.update(app, |this, cx| {
|
||||
let mut repo = gitcomet_state::model::RepoState::new_opening(
|
||||
repo_id,
|
||||
gitcomet_core::domain::RepoSpec {
|
||||
workdir: workdir.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
repo.status = gitcomet_state::model::Loadable::Loading;
|
||||
repo.diff_state.diff_target = Some(gitcomet_core::domain::DiffTarget::WorkingTree {
|
||||
path: file_rel.clone(),
|
||||
area: gitcomet_core::domain::DiffArea::Unstaged,
|
||||
});
|
||||
repo.diff_state.diff = gitcomet_state::model::Loadable::Loading;
|
||||
|
||||
let next_state = Arc::new(AppState {
|
||||
repos: vec![repo],
|
||||
active_repo: Some(repo_id),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
this._ui_model.update(cx, |model, cx| {
|
||||
model.set_state(Arc::clone(&next_state), cx);
|
||||
});
|
||||
|
||||
this.main_pane.update(cx, |pane, _cx| {
|
||||
pane.worktree_preview_path = Some(workdir.join(&file_rel));
|
||||
pane.worktree_preview = gitcomet_state::model::Loadable::Loading;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|window, app| {
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
|
||||
cx.update(|_window, app| {
|
||||
let pane = view.read(app).main_pane.read(app);
|
||||
assert!(
|
||||
pane.untracked_directory_notice().is_some(),
|
||||
"expected directory target to expose a non-file notice even while status is loading"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
pane.worktree_preview,
|
||||
gitcomet_state::model::Loadable::Loading
|
||||
),
|
||||
"directory target should not stay stuck in File Loading when status is loading"
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::remove_dir_all(&workdir).expect("cleanup directory-loading-status fixture");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn added_file_preview_ctrl_a_ctrl_c_copies_all_content(cx: &mut gpui::TestAppContext) {
|
||||
let repo_id = gitcomet_state::model::RepoId(31);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicI32, Ordering};
|
|||
|
||||
mod actions_impl;
|
||||
mod core_impl;
|
||||
mod diff_cache;
|
||||
pub(in crate::view) mod diff_cache;
|
||||
mod diff_search;
|
||||
mod diff_text;
|
||||
mod helpers;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ impl MainPaneView {
|
|||
kind: DiffClickKind,
|
||||
shift: bool,
|
||||
) {
|
||||
let list_len = self.diff_visible_indices.len();
|
||||
let list_len = self.diff_visible_len();
|
||||
if list_len == 0 {
|
||||
self.diff_selection_anchor = None;
|
||||
self.diff_selection_range = None;
|
||||
|
|
@ -76,7 +76,7 @@ impl MainPaneView {
|
|||
kind: DiffClickKind,
|
||||
shift: bool,
|
||||
) {
|
||||
let list_len = self.diff_visible_indices.len();
|
||||
let list_len = self.diff_visible_len();
|
||||
if list_len == 0 {
|
||||
self.diff_selection_anchor = None;
|
||||
self.diff_selection_range = None;
|
||||
|
|
@ -96,17 +96,19 @@ impl MainPaneView {
|
|||
DiffClickKind::Line => clicked_visible_ix,
|
||||
DiffClickKind::HunkHeader => self
|
||||
.diff_next_boundary_visible_ix(clicked_visible_ix, |src_ix| {
|
||||
let line = &self.diff_cache[src_ix];
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Hunk)
|
||||
|| (matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git "))
|
||||
self.patch_diff_row(src_ix).is_some_and(|line| {
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Hunk)
|
||||
|| (matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git "))
|
||||
})
|
||||
})
|
||||
.unwrap_or(list_len - 1),
|
||||
DiffClickKind::FileHeader => self
|
||||
.diff_next_boundary_visible_ix(clicked_visible_ix, |src_ix| {
|
||||
let line = &self.diff_cache[src_ix];
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ")
|
||||
self.patch_diff_row(src_ix).is_some_and(|line| {
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ")
|
||||
})
|
||||
})
|
||||
.unwrap_or(list_len - 1),
|
||||
};
|
||||
|
|
@ -116,7 +118,7 @@ impl MainPaneView {
|
|||
}
|
||||
|
||||
pub(super) fn handle_file_diff_row_click(&mut self, clicked_visible_ix: usize, shift: bool) {
|
||||
let list_len = self.diff_visible_indices.len();
|
||||
let list_len = self.diff_visible_len();
|
||||
if list_len == 0 {
|
||||
self.diff_selection_anchor = None;
|
||||
self.diff_selection_range = None;
|
||||
|
|
@ -140,10 +142,9 @@ impl MainPaneView {
|
|||
return Vec::new();
|
||||
}
|
||||
match self.diff_view {
|
||||
DiffViewMode::Inline => diff_navigation::change_block_entries(
|
||||
self.diff_visible_indices.len(),
|
||||
|visible_ix| {
|
||||
let Some(&inline_ix) = self.diff_visible_indices.get(visible_ix) else {
|
||||
DiffViewMode::Inline => {
|
||||
diff_navigation::change_block_entries(self.diff_visible_len(), |visible_ix| {
|
||||
let Some(inline_ix) = self.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return false;
|
||||
};
|
||||
self.file_diff_inline_cache.get(inline_ix).is_some_and(|l| {
|
||||
|
|
@ -153,28 +154,30 @@ impl MainPaneView {
|
|||
| gitcomet_core::domain::DiffLineKind::Remove
|
||||
)
|
||||
})
|
||||
},
|
||||
),
|
||||
DiffViewMode::Split => diff_navigation::change_block_entries(
|
||||
self.diff_visible_indices.len(),
|
||||
|visible_ix| {
|
||||
let Some(&row_ix) = self.diff_visible_indices.get(visible_ix) else {
|
||||
})
|
||||
}
|
||||
DiffViewMode::Split => {
|
||||
diff_navigation::change_block_entries(self.diff_visible_len(), |visible_ix| {
|
||||
let Some(row_ix) = self.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return false;
|
||||
};
|
||||
self.file_diff_cache_rows.get(row_ix).is_some_and(|row| {
|
||||
!matches!(row.kind, gitcomet_core::file_diff::FileDiffRowKind::Context)
|
||||
})
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn patch_hunk_entries(&self) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for (visible_ix, &ix) in self.diff_visible_indices.iter().enumerate() {
|
||||
for visible_ix in 0..self.diff_visible_len() {
|
||||
let Some(ix) = self.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
continue;
|
||||
};
|
||||
match self.diff_view {
|
||||
DiffViewMode::Inline => {
|
||||
let Some(line) = self.diff_cache.get(ix) else {
|
||||
let Some(line) = self.patch_diff_row(ix) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Hunk) {
|
||||
|
|
@ -182,7 +185,7 @@ impl MainPaneView {
|
|||
}
|
||||
}
|
||||
DiffViewMode::Split => {
|
||||
let Some(row) = self.diff_split_cache.get(ix) else {
|
||||
let Some(row) = self.patch_diff_split_row(ix) else {
|
||||
continue;
|
||||
};
|
||||
if let PatchSplitRow::Raw {
|
||||
|
|
@ -190,7 +193,7 @@ impl MainPaneView {
|
|||
click_kind: DiffClickKind::HunkHeader,
|
||||
} = row
|
||||
{
|
||||
out.push((visible_ix, *src_ix));
|
||||
out.push((visible_ix, src_ix));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -343,7 +346,7 @@ impl MainPaneView {
|
|||
} else {
|
||||
self.conflict_resolver_scroll_resolved_output_to_line(
|
||||
target,
|
||||
self.conflict_resolved_preview_lines.len().max(1),
|
||||
self.conflict_resolved_preview_line_count.max(1),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -404,7 +407,7 @@ impl MainPaneView {
|
|||
} else {
|
||||
self.conflict_resolver_scroll_resolved_output_to_line(
|
||||
target,
|
||||
self.conflict_resolved_preview_lines.len().max(1),
|
||||
self.conflict_resolved_preview_line_count.max(1),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -561,7 +564,7 @@ impl MainPaneView {
|
|||
self.diff_autoscroll_pending = false;
|
||||
return;
|
||||
}
|
||||
if self.diff_visible_indices.is_empty() {
|
||||
if self.diff_visible_len() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -762,29 +765,35 @@ impl MainPaneView {
|
|||
&inline_rows,
|
||||
);
|
||||
|
||||
fn split_lines_shared(text: &str) -> Vec<SharedString> {
|
||||
let three_way_text = ThreeWaySides {
|
||||
base: SharedString::from(base_text.to_string()),
|
||||
ours: SharedString::from(ours_text.to_string()),
|
||||
theirs: SharedString::from(theirs_text.to_string()),
|
||||
};
|
||||
let three_way_line_starts = ThreeWaySides {
|
||||
base: build_line_starts(base_text),
|
||||
ours: build_line_starts(ours_text),
|
||||
theirs: build_line_starts(theirs_text),
|
||||
};
|
||||
let side_line_count = |text: &str| {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
0
|
||||
} else {
|
||||
count_newlines(text).saturating_add(1)
|
||||
}
|
||||
let mut out =
|
||||
Vec::with_capacity(text.as_bytes().iter().filter(|&&b| b == b'\n').count() + 1);
|
||||
out.extend(text.lines().map(|line| line.to_string().into()));
|
||||
out
|
||||
}
|
||||
|
||||
let three_way_base_lines = split_lines_shared(base_text);
|
||||
let three_way_ours_lines = split_lines_shared(ours_text);
|
||||
let three_way_theirs_lines = split_lines_shared(theirs_text);
|
||||
let three_way_len = three_way_base_lines
|
||||
.len()
|
||||
.max(three_way_ours_lines.len())
|
||||
.max(three_way_theirs_lines.len());
|
||||
};
|
||||
let three_way_base_len = side_line_count(base_text);
|
||||
let three_way_ours_len = side_line_count(ours_text);
|
||||
let three_way_theirs_len = side_line_count(theirs_text);
|
||||
let three_way_len = three_way_base_len
|
||||
.max(three_way_ours_len)
|
||||
.max(three_way_theirs_len);
|
||||
|
||||
let three_way_conflict_maps = conflict_resolver::build_three_way_conflict_maps(
|
||||
&marker_segments,
|
||||
three_way_base_lines.len(),
|
||||
three_way_ours_lines.len(),
|
||||
three_way_theirs_lines.len(),
|
||||
three_way_base_len,
|
||||
three_way_ours_len,
|
||||
three_way_theirs_len,
|
||||
);
|
||||
|
||||
let view_mode = if self.conflict_resolver.repo_id == Some(repo_id)
|
||||
|
|
@ -846,14 +855,20 @@ impl MainPaneView {
|
|||
three_way_word_highlights_base,
|
||||
three_way_word_highlights_ours,
|
||||
three_way_word_highlights_theirs,
|
||||
) = conflict_resolver::compute_three_way_word_highlights(
|
||||
&three_way_base_lines,
|
||||
&three_way_ours_lines,
|
||||
&three_way_theirs_lines,
|
||||
&marker_segments,
|
||||
);
|
||||
let diff_word_highlights_split =
|
||||
conflict_resolver::compute_two_way_word_highlights(&diff_rows);
|
||||
diff_word_highlights_split,
|
||||
) = {
|
||||
let (base, ours, theirs) = conflict_resolver::compute_three_way_word_highlights(
|
||||
&three_way_text.base,
|
||||
&three_way_line_starts.base,
|
||||
&three_way_text.ours,
|
||||
&three_way_line_starts.ours,
|
||||
&three_way_text.theirs,
|
||||
&three_way_line_starts.theirs,
|
||||
&marker_segments,
|
||||
);
|
||||
let split = conflict_resolver::compute_two_way_word_highlights(&diff_rows);
|
||||
(base, ours, theirs, split)
|
||||
};
|
||||
|
||||
self.conflict_three_way_segments_cache.clear();
|
||||
|
||||
|
|
@ -887,11 +902,8 @@ impl MainPaneView {
|
|||
view_mode,
|
||||
diff_rows,
|
||||
inline_rows,
|
||||
three_way_lines: ThreeWaySides {
|
||||
base: three_way_base_lines,
|
||||
ours: three_way_ours_lines,
|
||||
theirs: three_way_theirs_lines,
|
||||
},
|
||||
three_way_text,
|
||||
three_way_line_starts,
|
||||
three_way_len,
|
||||
three_way_conflict_ranges: three_way_conflict_maps.conflict_ranges,
|
||||
three_way_line_conflict_map: ThreeWaySides {
|
||||
|
|
@ -940,7 +952,7 @@ impl MainPaneView {
|
|||
let output_path = self.conflict_resolver.path.clone();
|
||||
self.conflict_resolved_preview_path = output_path.clone();
|
||||
self.conflict_resolved_preview_source_hash = Some(output_hash);
|
||||
self.schedule_conflict_resolved_outline_recompute(output_path, output_hash, cx);
|
||||
self.schedule_conflict_resolved_outline_recompute(output_path, output_hash, None, cx);
|
||||
|
||||
if self.diff_search_active && !self.diff_search_query.as_ref().trim().is_empty() {
|
||||
self.diff_search_recompute_matches();
|
||||
|
|
@ -1011,9 +1023,12 @@ impl MainPaneView {
|
|||
|
||||
let three_way_conflict_maps = conflict_resolver::build_three_way_conflict_maps(
|
||||
&marker_segments,
|
||||
self.conflict_resolver.three_way_lines.base.len(),
|
||||
self.conflict_resolver.three_way_lines.ours.len(),
|
||||
self.conflict_resolver.three_way_lines.theirs.len(),
|
||||
self.conflict_resolver
|
||||
.three_way_line_count(ThreeWayColumn::Base),
|
||||
self.conflict_resolver
|
||||
.three_way_line_count(ThreeWayColumn::Ours),
|
||||
self.conflict_resolver
|
||||
.three_way_line_count(ThreeWayColumn::Theirs),
|
||||
);
|
||||
|
||||
// Recompute row→conflict maps using existing diff/inline rows.
|
||||
|
|
@ -1102,7 +1117,7 @@ impl MainPaneView {
|
|||
let output_path = self.conflict_resolver.path.clone();
|
||||
self.conflict_resolved_preview_path = output_path.clone();
|
||||
self.conflict_resolved_preview_source_hash = Some(output_hash);
|
||||
self.schedule_conflict_resolved_outline_recompute(output_path, output_hash, cx);
|
||||
self.schedule_conflict_resolved_outline_recompute(output_path, output_hash, None, cx);
|
||||
|
||||
if self.diff_search_active && !self.diff_search_query.as_ref().trim().is_empty() {
|
||||
self.diff_search_recompute_matches();
|
||||
|
|
@ -1178,9 +1193,12 @@ impl MainPaneView {
|
|||
pub(super) fn conflict_resolver_rebuild_visible_map(&mut self) {
|
||||
let three_way_conflict_maps = conflict_resolver::build_three_way_conflict_maps(
|
||||
&self.conflict_resolver.marker_segments,
|
||||
self.conflict_resolver.three_way_lines.base.len(),
|
||||
self.conflict_resolver.three_way_lines.ours.len(),
|
||||
self.conflict_resolver.three_way_lines.theirs.len(),
|
||||
self.conflict_resolver
|
||||
.three_way_line_count(ThreeWayColumn::Base),
|
||||
self.conflict_resolver
|
||||
.three_way_line_count(ThreeWayColumn::Ours),
|
||||
self.conflict_resolver
|
||||
.three_way_line_count(ThreeWayColumn::Theirs),
|
||||
);
|
||||
self.conflict_resolver.three_way_conflict_ranges = three_way_conflict_maps.conflict_ranges;
|
||||
self.conflict_resolver.three_way_line_conflict_map.base =
|
||||
|
|
@ -1468,17 +1486,21 @@ impl MainPaneView {
|
|||
self.conflict_resolver_output_replace_line(line_ix, choice, cx);
|
||||
return;
|
||||
}
|
||||
let current = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |i, _| i.text().to_string());
|
||||
let append_line_ix = source_line_count(¤t);
|
||||
let next = conflict_resolver::append_lines_to_output(¤t, &[line.to_string()]);
|
||||
let line_to_append = line.to_string();
|
||||
let theme = self.theme;
|
||||
let mut append_line_ix = 0usize;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_theme(theme, cx);
|
||||
input.set_text(next.clone(), cx);
|
||||
let content = input.text();
|
||||
append_line_ix = source_line_count(content);
|
||||
let insertion = append_line_insertion_text(content, line_to_append.as_str());
|
||||
let end = content.len();
|
||||
input.replace_utf8_range(end..end, &insertion, cx);
|
||||
});
|
||||
self.conflict_resolver_scroll_resolved_output_to_line_in_text(append_line_ix, &next);
|
||||
let next_line_count = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |input, _| split_line_count(input.text()));
|
||||
self.conflict_resolver_scroll_resolved_output_to_line(append_line_ix, next_line_count);
|
||||
}
|
||||
|
||||
/// Immediately append a single line from the two-way inline view to resolved output.
|
||||
|
|
@ -1506,18 +1528,21 @@ impl MainPaneView {
|
|||
self.conflict_resolver_output_replace_line(line_ix, choice, cx);
|
||||
return;
|
||||
}
|
||||
let current = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |i, _| i.text().to_string());
|
||||
let append_line_ix = source_line_count(¤t);
|
||||
let next =
|
||||
conflict_resolver::append_lines_to_output(¤t, std::slice::from_ref(&row.content));
|
||||
let line_to_append = row.content.clone();
|
||||
let theme = self.theme;
|
||||
let mut append_line_ix = 0usize;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_theme(theme, cx);
|
||||
input.set_text(next.clone(), cx);
|
||||
let content = input.text();
|
||||
append_line_ix = source_line_count(content);
|
||||
let insertion = append_line_insertion_text(content, line_to_append.as_str());
|
||||
let end = content.len();
|
||||
input.replace_utf8_range(end..end, &insertion, cx);
|
||||
});
|
||||
self.conflict_resolver_scroll_resolved_output_to_line_in_text(append_line_ix, &next);
|
||||
let next_line_count = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |input, _| split_line_count(input.text()));
|
||||
self.conflict_resolver_scroll_resolved_output_to_line(append_line_ix, next_line_count);
|
||||
}
|
||||
|
||||
/// Immediately append a single line from the three-way view to resolved output.
|
||||
|
|
@ -1528,15 +1553,15 @@ impl MainPaneView {
|
|||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
let line = match choice {
|
||||
conflict_resolver::ConflictChoice::Base => {
|
||||
self.conflict_resolver.three_way_lines.base.get(line_ix)
|
||||
}
|
||||
conflict_resolver::ConflictChoice::Ours => {
|
||||
self.conflict_resolver.three_way_lines.ours.get(line_ix)
|
||||
}
|
||||
conflict_resolver::ConflictChoice::Theirs => {
|
||||
self.conflict_resolver.three_way_lines.theirs.get(line_ix)
|
||||
}
|
||||
conflict_resolver::ConflictChoice::Base => self
|
||||
.conflict_resolver
|
||||
.three_way_line_text(ThreeWayColumn::Base, line_ix),
|
||||
conflict_resolver::ConflictChoice::Ours => self
|
||||
.conflict_resolver
|
||||
.three_way_line_text(ThreeWayColumn::Ours, line_ix),
|
||||
conflict_resolver::ConflictChoice::Theirs => self
|
||||
.conflict_resolver
|
||||
.three_way_line_text(ThreeWayColumn::Theirs, line_ix),
|
||||
conflict_resolver::ConflictChoice::Both => {
|
||||
// Both is chunk-level only, not line-level.
|
||||
return;
|
||||
|
|
@ -1560,7 +1585,46 @@ impl MainPaneView {
|
|||
let next_text = text;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_theme(theme, cx);
|
||||
input.set_text(next_text, cx);
|
||||
if input.text() == next_text {
|
||||
return;
|
||||
}
|
||||
let current = input.text();
|
||||
let old = current.as_bytes();
|
||||
let new = next_text.as_bytes();
|
||||
let old_len = old.len();
|
||||
let new_len = new.len();
|
||||
|
||||
let mut prefix = 0usize;
|
||||
let prefix_max = old_len.min(new_len);
|
||||
while prefix < prefix_max && old[prefix] == new[prefix] {
|
||||
prefix = prefix.saturating_add(1);
|
||||
}
|
||||
while prefix > 0
|
||||
&& (!current.is_char_boundary(prefix) || !next_text.is_char_boundary(prefix))
|
||||
{
|
||||
prefix = prefix.saturating_sub(1);
|
||||
}
|
||||
|
||||
let mut suffix = 0usize;
|
||||
while suffix < old_len.saturating_sub(prefix)
|
||||
&& suffix < new_len.saturating_sub(prefix)
|
||||
&& old[old_len.saturating_sub(1 + suffix)]
|
||||
== new[new_len.saturating_sub(1 + suffix)]
|
||||
{
|
||||
suffix = suffix.saturating_add(1);
|
||||
}
|
||||
while suffix > 0
|
||||
&& (!current.is_char_boundary(old_len.saturating_sub(suffix))
|
||||
|| !next_text.is_char_boundary(new_len.saturating_sub(suffix)))
|
||||
{
|
||||
suffix = suffix.saturating_sub(1);
|
||||
}
|
||||
|
||||
let old_range = prefix..old_len.saturating_sub(suffix);
|
||||
let replacement = next_text
|
||||
.get(prefix..new_len.saturating_sub(suffix))
|
||||
.unwrap_or("");
|
||||
input.replace_utf8_range(old_range, replacement, cx);
|
||||
});
|
||||
if unchanged {
|
||||
// Choosing a chunk can flip resolved/unresolved state without changing output text.
|
||||
|
|
@ -1576,20 +1640,14 @@ impl MainPaneView {
|
|||
&mut self,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
let (content, sel_range) = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |i, _| (i.text().to_string(), i.selected_range()));
|
||||
if sel_range.is_empty() {
|
||||
return;
|
||||
}
|
||||
let start = sel_range.start.min(content.len());
|
||||
let end = sel_range.end.min(content.len());
|
||||
let mut next = content[..start].to_string();
|
||||
next.push_str(&content[end..]);
|
||||
let theme = self.theme;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
let selection = input.selected_range();
|
||||
if selection.is_empty() {
|
||||
return;
|
||||
}
|
||||
input.set_theme(theme, cx);
|
||||
input.set_text(next, cx);
|
||||
let _ = input.replace_selection_utf8("", cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1599,17 +1657,11 @@ impl MainPaneView {
|
|||
paste_text: &str,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
let (content, cursor_offset) = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |i, _| (i.text().to_string(), i.cursor_offset()));
|
||||
let pos = cursor_offset.min(content.len());
|
||||
let mut next = content[..pos].to_string();
|
||||
next.push_str(paste_text);
|
||||
next.push_str(&content[pos..]);
|
||||
let theme = self.theme;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
let pos = input.cursor_offset().min(input.text().len());
|
||||
input.set_theme(theme, cx);
|
||||
input.set_text(next, cx);
|
||||
input.replace_utf8_range(pos..pos, paste_text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1627,22 +1679,16 @@ impl MainPaneView {
|
|||
match choice {
|
||||
conflict_resolver::ConflictChoice::Base => self
|
||||
.conflict_resolver
|
||||
.three_way_lines
|
||||
.base
|
||||
.get(line_ix)
|
||||
.map(|s| s.to_string()),
|
||||
.three_way_line_text(ThreeWayColumn::Base, line_ix)
|
||||
.map(ToString::to_string),
|
||||
conflict_resolver::ConflictChoice::Ours => self
|
||||
.conflict_resolver
|
||||
.three_way_lines
|
||||
.ours
|
||||
.get(line_ix)
|
||||
.map(|s| s.to_string()),
|
||||
.three_way_line_text(ThreeWayColumn::Ours, line_ix)
|
||||
.map(ToString::to_string),
|
||||
conflict_resolver::ConflictChoice::Theirs => self
|
||||
.conflict_resolver
|
||||
.three_way_lines
|
||||
.theirs
|
||||
.get(line_ix)
|
||||
.map(|s| s.to_string()),
|
||||
.three_way_line_text(ThreeWayColumn::Theirs, line_ix)
|
||||
.map(ToString::to_string),
|
||||
conflict_resolver::ConflictChoice::Both => return,
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1679,38 +1725,29 @@ impl MainPaneView {
|
|||
return;
|
||||
};
|
||||
|
||||
let current = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |i, _| i.text().to_string());
|
||||
let lines: Vec<&str> = current.split('\n').collect();
|
||||
|
||||
if line_ix < lines.len() {
|
||||
let mut next = String::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i > 0 {
|
||||
next.push('\n');
|
||||
}
|
||||
if i == line_ix {
|
||||
next.push_str(&replacement);
|
||||
} else {
|
||||
next.push_str(line);
|
||||
}
|
||||
let theme = self.theme;
|
||||
let mut scroll_to_line = None;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_theme(theme, cx);
|
||||
let content = input.text();
|
||||
if let Some(range) = line_content_byte_range_for_index(content, line_ix) {
|
||||
input.replace_utf8_range(range, &replacement, cx);
|
||||
scroll_to_line = Some(line_ix);
|
||||
return;
|
||||
}
|
||||
let theme = self.theme;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_theme(theme, cx);
|
||||
input.set_text(next.clone(), cx);
|
||||
});
|
||||
self.conflict_resolver_scroll_resolved_output_to_line_in_text(line_ix, &next);
|
||||
} else {
|
||||
let append_line_ix = source_line_count(¤t);
|
||||
let next = conflict_resolver::append_lines_to_output(¤t, &[replacement]);
|
||||
let theme = self.theme;
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_theme(theme, cx);
|
||||
input.set_text(next.clone(), cx);
|
||||
});
|
||||
self.conflict_resolver_scroll_resolved_output_to_line_in_text(append_line_ix, &next);
|
||||
|
||||
let append_line_ix = source_line_count(content);
|
||||
let insertion = append_line_insertion_text(content, &replacement);
|
||||
let end = content.len();
|
||||
input.replace_utf8_range(end..end, &insertion, cx);
|
||||
scroll_to_line = Some(append_line_ix);
|
||||
});
|
||||
|
||||
if let Some(target_line_ix) = scroll_to_line {
|
||||
let line_count = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |input, _| split_line_count(input.text()));
|
||||
self.conflict_resolver_scroll_resolved_output_to_line(target_line_ix, line_count);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,130 @@
|
|||
use super::helpers::*;
|
||||
use super::*;
|
||||
|
||||
fn resolved_output_line_text<'a>(text: &'a str, line_starts: &[usize], line_ix: usize) -> &'a str {
|
||||
if text.is_empty() {
|
||||
return "";
|
||||
}
|
||||
let text_len = text.len();
|
||||
let start = line_starts.get(line_ix).copied().unwrap_or(text_len);
|
||||
if start >= text_len {
|
||||
return "";
|
||||
}
|
||||
let mut end = line_starts
|
||||
.get(line_ix.saturating_add(1))
|
||||
.copied()
|
||||
.unwrap_or(text_len)
|
||||
.min(text_len);
|
||||
if end > start && text.as_bytes().get(end.saturating_sub(1)) == Some(&b'\n') {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
text.get(start..end).unwrap_or("")
|
||||
}
|
||||
|
||||
fn line_ranges_intersect(a: &Range<usize>, b: &Range<usize>) -> bool {
|
||||
a.start < b.end && b.start < a.end
|
||||
}
|
||||
|
||||
fn shift_resolved_output_marker(
|
||||
marker: ResolvedOutputConflictMarker,
|
||||
line_delta: isize,
|
||||
) -> ResolvedOutputConflictMarker {
|
||||
ResolvedOutputConflictMarker {
|
||||
conflict_ix: marker.conflict_ix,
|
||||
range_start: shifted_line_index(marker.range_start, line_delta),
|
||||
range_end: shifted_line_index(marker.range_end, line_delta),
|
||||
is_start: marker.is_start,
|
||||
is_end: marker.is_end,
|
||||
unresolved: marker.unresolved,
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_line_count(text: &str, line_starts: &[usize]) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
line_starts.len().max(1)
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_lookup_from_indexed_text<'a>(
|
||||
lookup: &mut HashMap<&'a str, (conflict_resolver::ResolvedLineSource, Option<u32>)>,
|
||||
source: conflict_resolver::ResolvedLineSource,
|
||||
text: &'a str,
|
||||
line_starts: &[usize],
|
||||
) {
|
||||
let line_count = indexed_line_count(text, line_starts);
|
||||
for line_ix in (0..line_count).rev() {
|
||||
let line = resolved_output_line_text(text, line_starts, line_ix);
|
||||
lookup.insert(
|
||||
line,
|
||||
(
|
||||
source,
|
||||
Some(u32::try_from(line_ix.saturating_add(1)).unwrap_or(u32::MAX)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_two_way_side_lookup<'a>(
|
||||
lookup: &mut HashMap<&'a str, (conflict_resolver::ResolvedLineSource, Option<u32>)>,
|
||||
rows: &'a [gitcomet_core::file_diff::FileDiffRow],
|
||||
source: conflict_resolver::ResolvedLineSource,
|
||||
read_text: impl Fn(&'a gitcomet_core::file_diff::FileDiffRow) -> Option<&'a str>,
|
||||
) {
|
||||
let mut line_no = rows
|
||||
.iter()
|
||||
.filter_map(&read_text)
|
||||
.count()
|
||||
.min(u32::MAX as usize) as u32;
|
||||
for row in rows.iter().rev() {
|
||||
let Some(text) = read_text(row) else {
|
||||
continue;
|
||||
};
|
||||
if line_no == 0 {
|
||||
continue;
|
||||
}
|
||||
lookup.insert(text, (source, Some(line_no)));
|
||||
line_no = line_no.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_line_sources_index_for_range(
|
||||
index: &mut HashSet<conflict_resolver::SourceLineKey>,
|
||||
view_mode: ConflictResolverViewMode,
|
||||
meta: &[conflict_resolver::ResolvedLineMeta],
|
||||
text: &str,
|
||||
line_starts: &[usize],
|
||||
line_range: Range<usize>,
|
||||
insert: bool,
|
||||
) {
|
||||
if line_range.start >= line_range.end {
|
||||
return;
|
||||
}
|
||||
for line_ix in line_range {
|
||||
let Some(line_meta) = meta.get(line_ix) else {
|
||||
break;
|
||||
};
|
||||
if line_meta.source == conflict_resolver::ResolvedLineSource::Manual {
|
||||
continue;
|
||||
}
|
||||
let Some(line_no) = line_meta.input_line else {
|
||||
continue;
|
||||
};
|
||||
let key = conflict_resolver::SourceLineKey::new(
|
||||
view_mode,
|
||||
line_meta.source,
|
||||
line_no,
|
||||
resolved_output_line_text(text, line_starts, line_ix),
|
||||
);
|
||||
if insert {
|
||||
index.insert(key);
|
||||
} else {
|
||||
index.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MainPaneView {
|
||||
pub(super) fn notify_fingerprint_for(state: &AppState) -> u64 {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
|
|
@ -186,10 +310,25 @@ impl MainPaneView {
|
|||
|
||||
let conflict_resolver_subscription =
|
||||
cx.observe(&conflict_resolver_input, |this, input, cx| {
|
||||
let output_text = input.read(cx).text().to_string();
|
||||
let (output_text, edit_delta) = input.update(cx, |input, _| {
|
||||
(
|
||||
input.text().to_string(),
|
||||
input.take_recent_utf8_edit_delta(),
|
||||
)
|
||||
});
|
||||
let mut output_hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
output_text.hash(&mut output_hasher);
|
||||
let output_hash = output_hasher.finish();
|
||||
let outline_delta = resolved_outline_delta_between_texts(
|
||||
this.conflict_resolved_preview_text.as_ref(),
|
||||
&output_text,
|
||||
)
|
||||
.or_else(|| {
|
||||
edit_delta.map(|(old_range, new_range)| ResolvedOutlineDelta {
|
||||
old_range,
|
||||
new_range,
|
||||
})
|
||||
});
|
||||
|
||||
let path = this.conflict_resolver.path.clone();
|
||||
let needs_update = this.conflict_resolved_preview_path.as_ref() != path.as_ref()
|
||||
|
|
@ -200,7 +339,12 @@ impl MainPaneView {
|
|||
|
||||
this.conflict_resolved_preview_path = path.clone();
|
||||
this.conflict_resolved_preview_source_hash = Some(output_hash);
|
||||
this.schedule_conflict_resolved_outline_recompute(path, output_hash, cx);
|
||||
this.schedule_conflict_resolved_outline_recompute(
|
||||
path,
|
||||
output_hash,
|
||||
outline_delta,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let diff_search_input = cx.new(|cx| {
|
||||
|
|
@ -220,7 +364,7 @@ impl MainPaneView {
|
|||
let next: SharedString = input.read(cx).text().to_string().into();
|
||||
if this.diff_search_query != next {
|
||||
this.diff_search_query = next;
|
||||
this.diff_text_segments_cache.clear();
|
||||
this.clear_diff_text_query_overlay_cache();
|
||||
this.worktree_preview_segments_cache_path = None;
|
||||
this.worktree_preview_segments_cache.clear();
|
||||
this.clear_conflict_diff_query_overlay_caches();
|
||||
|
|
@ -277,9 +421,13 @@ impl MainPaneView {
|
|||
diff_cache_rev: 0,
|
||||
diff_cache_target: None,
|
||||
diff_cache: Vec::new(),
|
||||
diff_row_provider: None,
|
||||
diff_split_row_provider: None,
|
||||
diff_file_for_src_ix: Vec::new(),
|
||||
diff_language_for_src_ix: Vec::new(),
|
||||
diff_click_kinds: Vec::new(),
|
||||
diff_line_kind_for_src_ix: Vec::new(),
|
||||
diff_hide_unified_header_for_src_ix: Vec::new(),
|
||||
diff_header_display_cache: HashMap::default(),
|
||||
diff_split_cache: Vec::new(),
|
||||
diff_split_cache_len: 0,
|
||||
|
|
@ -287,15 +435,17 @@ impl MainPaneView {
|
|||
diff_autoscroll_pending: false,
|
||||
diff_raw_input,
|
||||
diff_visible_indices: Vec::new(),
|
||||
diff_visible_inline_map: None,
|
||||
diff_visible_cache_len: 0,
|
||||
diff_visible_view: DiffViewMode::Split,
|
||||
diff_visible_is_file_view: false,
|
||||
diff_scrollbar_markers_cache: Vec::new(),
|
||||
diff_word_highlights: Vec::new(),
|
||||
diff_word_highlights_seq: 0,
|
||||
diff_word_highlights_inflight: None,
|
||||
diff_file_stats: Vec::new(),
|
||||
diff_text_segments_cache: Vec::new(),
|
||||
diff_text_query_segments_cache: Vec::new(),
|
||||
diff_text_query_cache_query: SharedString::default(),
|
||||
diff_selection_anchor: None,
|
||||
diff_selection_range: None,
|
||||
diff_text_selecting: false,
|
||||
|
|
@ -327,6 +477,8 @@ impl MainPaneView {
|
|||
file_diff_split_word_highlights_new: Vec::new(),
|
||||
file_diff_cache_seq: 0,
|
||||
file_diff_cache_inflight: None,
|
||||
file_diff_syntax_generation: 0,
|
||||
prepared_syntax_documents: HashMap::default(),
|
||||
file_image_diff_cache_repo_id: None,
|
||||
file_image_diff_cache_rev: 0,
|
||||
file_image_diff_cache_target: None,
|
||||
|
|
@ -337,6 +489,7 @@ impl MainPaneView {
|
|||
file_image_diff_cache_new_svg_path: None,
|
||||
worktree_preview_path: None,
|
||||
worktree_preview: Loadable::NotLoaded,
|
||||
worktree_preview_content_rev: 0,
|
||||
worktree_preview_segments_cache_path: None,
|
||||
worktree_preview_syntax_language: None,
|
||||
worktree_preview_segments_cache: HashMap::default(),
|
||||
|
|
@ -362,8 +515,10 @@ impl MainPaneView {
|
|||
conflict_three_way_segments_cache: HashMap::default(),
|
||||
conflict_resolved_preview_path: None,
|
||||
conflict_resolved_preview_source_hash: None,
|
||||
conflict_resolved_preview_text: SharedString::default(),
|
||||
conflict_resolved_preview_syntax_language: None,
|
||||
conflict_resolved_preview_lines: Vec::new(),
|
||||
conflict_resolved_preview_line_count: 0,
|
||||
conflict_resolved_preview_line_starts: Vec::new(),
|
||||
conflict_resolved_preview_segments_cache: HashMap::default(),
|
||||
history_view,
|
||||
diff_scroll: UniformListScrollHandle::default(),
|
||||
|
|
@ -381,7 +536,7 @@ impl MainPaneView {
|
|||
|
||||
pub(in crate::view) fn set_theme(&mut self, theme: AppTheme, cx: &mut gpui::Context<Self>) {
|
||||
self.theme = theme;
|
||||
self.diff_text_segments_cache.clear();
|
||||
self.clear_diff_text_style_caches();
|
||||
self.worktree_preview_segments_cache_path = None;
|
||||
self.worktree_preview_segments_cache.clear();
|
||||
self.conflict_diff_segments_cache_split.clear();
|
||||
|
|
@ -396,9 +551,12 @@ impl MainPaneView {
|
|||
.update(cx, |input, cx| input.set_theme(theme, cx));
|
||||
self.conflict_resolver_input
|
||||
.update(cx, |input, cx| input.set_theme(theme, cx));
|
||||
let output_text = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |input, _| input.text().to_string());
|
||||
let output_syntax_highlights = build_resolved_output_syntax_highlights(
|
||||
theme,
|
||||
&self.conflict_resolved_preview_lines,
|
||||
&output_text,
|
||||
self.conflict_resolved_preview_syntax_language,
|
||||
);
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
|
|
@ -412,6 +570,23 @@ impl MainPaneView {
|
|||
cx.notify();
|
||||
}
|
||||
|
||||
pub(in crate::view) fn clear_diff_text_query_overlay_cache(&mut self) {
|
||||
self.diff_text_query_segments_cache.clear();
|
||||
self.diff_text_query_cache_query = SharedString::default();
|
||||
}
|
||||
|
||||
pub(in crate::view) fn sync_diff_text_query_overlay_cache(&mut self, query: &str) {
|
||||
if self.diff_text_query_cache_query.as_ref() != query {
|
||||
self.diff_text_query_cache_query = query.to_string().into();
|
||||
self.diff_text_query_segments_cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::view) fn clear_diff_text_style_caches(&mut self) {
|
||||
self.diff_text_segments_cache.clear();
|
||||
self.clear_diff_text_query_overlay_cache();
|
||||
}
|
||||
|
||||
pub(in crate::view) fn clear_conflict_diff_query_overlay_caches(&mut self) {
|
||||
self.conflict_diff_query_segments_cache_split.clear();
|
||||
self.conflict_diff_query_segments_cache_inline.clear();
|
||||
|
|
@ -439,8 +614,10 @@ impl MainPaneView {
|
|||
.wrapping_add(1);
|
||||
self.conflict_resolved_preview_path = None;
|
||||
self.conflict_resolved_preview_source_hash = None;
|
||||
self.conflict_resolved_preview_text = SharedString::default();
|
||||
self.conflict_resolved_preview_syntax_language = None;
|
||||
self.conflict_resolved_preview_lines.clear();
|
||||
self.conflict_resolved_preview_line_count = 0;
|
||||
self.conflict_resolved_preview_line_starts.clear();
|
||||
self.conflict_resolved_preview_segments_cache.clear();
|
||||
self.conflict_resolver.resolved_line_meta.clear();
|
||||
self.conflict_resolver
|
||||
|
|
@ -457,17 +634,20 @@ impl MainPaneView {
|
|||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
let _perf_scope = perf::span(ConflictPerfSpan::RecomputeResolvedOutline);
|
||||
self.conflict_resolved_preview_syntax_language =
|
||||
path.and_then(rows::diff_syntax_language_for_path);
|
||||
let output_text = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |input, _| input.text().to_string());
|
||||
self.conflict_resolved_preview_lines =
|
||||
conflict_resolver::split_output_lines_for_outline(&output_text);
|
||||
let output_line_starts = build_line_starts(&output_text);
|
||||
let output_line_count = output_line_starts.len().max(1);
|
||||
self.conflict_resolved_preview_line_starts = output_line_starts;
|
||||
|
||||
self.conflict_resolved_preview_syntax_language =
|
||||
path.and_then(rows::diff_syntax_language_for_path);
|
||||
self.conflict_resolved_preview_line_count = output_line_count;
|
||||
self.conflict_resolved_preview_segments_cache.clear();
|
||||
let output_syntax_highlights = build_resolved_output_syntax_highlights(
|
||||
self.theme,
|
||||
&self.conflict_resolved_preview_lines,
|
||||
&output_text,
|
||||
self.conflict_resolved_preview_syntax_language,
|
||||
);
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
|
|
@ -476,27 +656,25 @@ impl MainPaneView {
|
|||
|
||||
// Provenance: classify each output line as A/B/C/Manual.
|
||||
let view_mode = self.conflict_resolver.view_mode;
|
||||
let (two_way_old, two_way_new) = if view_mode == ConflictResolverViewMode::TwoWayDiff {
|
||||
collect_two_way_source_lines(&self.conflict_resolver.diff_rows)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
let mut meta = match view_mode {
|
||||
ConflictResolverViewMode::ThreeWay => {
|
||||
conflict_resolver::compute_resolved_line_provenance_from_text_with_indexed_sources(
|
||||
&output_text,
|
||||
&self.conflict_resolver.three_way_text.base,
|
||||
&self.conflict_resolver.three_way_line_starts.base,
|
||||
&self.conflict_resolver.three_way_text.ours,
|
||||
&self.conflict_resolver.three_way_line_starts.ours,
|
||||
&self.conflict_resolver.three_way_text.theirs,
|
||||
&self.conflict_resolver.three_way_line_starts.theirs,
|
||||
)
|
||||
}
|
||||
ConflictResolverViewMode::TwoWayDiff => {
|
||||
conflict_resolver::compute_resolved_line_provenance_from_text_two_way_rows(
|
||||
&output_text,
|
||||
&self.conflict_resolver.diff_rows,
|
||||
)
|
||||
}
|
||||
};
|
||||
let sources = match view_mode {
|
||||
ConflictResolverViewMode::ThreeWay => conflict_resolver::SourceLines {
|
||||
a: &self.conflict_resolver.three_way_lines.base,
|
||||
b: &self.conflict_resolver.three_way_lines.ours,
|
||||
c: &self.conflict_resolver.three_way_lines.theirs,
|
||||
},
|
||||
ConflictResolverViewMode::TwoWayDiff => conflict_resolver::SourceLines {
|
||||
a: &two_way_old,
|
||||
b: &two_way_new,
|
||||
c: &[],
|
||||
},
|
||||
};
|
||||
let mut meta = conflict_resolver::compute_resolved_line_provenance(
|
||||
&self.conflict_resolved_preview_lines,
|
||||
&sources,
|
||||
);
|
||||
apply_conflict_choice_provenance_hints(
|
||||
&mut meta,
|
||||
&self.conflict_resolver.marker_segments,
|
||||
|
|
@ -504,18 +682,302 @@ impl MainPaneView {
|
|||
view_mode,
|
||||
);
|
||||
self.conflict_resolver.resolved_output_line_sources_index =
|
||||
conflict_resolver::build_resolved_output_line_sources_index(
|
||||
conflict_resolver::build_resolved_output_line_sources_index_from_text(
|
||||
&meta,
|
||||
&self.conflict_resolved_preview_lines,
|
||||
&output_text,
|
||||
view_mode,
|
||||
);
|
||||
self.conflict_resolver.resolved_output_conflict_markers =
|
||||
build_resolved_output_conflict_markers(
|
||||
&self.conflict_resolver.marker_segments,
|
||||
&output_text,
|
||||
self.conflict_resolved_preview_lines.len(),
|
||||
output_line_count,
|
||||
);
|
||||
self.conflict_resolver.resolved_line_meta = meta;
|
||||
self.conflict_resolved_preview_text = output_text.into();
|
||||
}
|
||||
|
||||
fn recompute_conflict_resolved_outline_and_provenance_incremental(
|
||||
&mut self,
|
||||
path: Option<&std::path::PathBuf>,
|
||||
delta: ResolvedOutlineDelta,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> bool {
|
||||
let old_text = self.conflict_resolved_preview_text.to_string();
|
||||
let output_text = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |input, _| input.text().to_string());
|
||||
let old_line_starts = self.conflict_resolved_preview_line_starts.clone();
|
||||
let old_line_count = old_line_starts.len().max(1);
|
||||
let new_line_starts = build_line_starts(&output_text);
|
||||
let new_line_count = new_line_starts.len().max(1);
|
||||
if old_line_starts.is_empty()
|
||||
|| self.conflict_resolver.resolved_line_meta.len() != old_line_count
|
||||
|| self
|
||||
.conflict_resolver
|
||||
.resolved_output_conflict_markers
|
||||
.len()
|
||||
!= old_line_count
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if delta.old_range.start > delta.old_range.end
|
||||
|| delta.new_range.start > delta.new_range.end
|
||||
|| delta.old_range.end > old_text.len()
|
||||
|| delta.new_range.end > output_text.len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut old_affected = dirty_byte_range_to_line_range(
|
||||
&old_line_starts,
|
||||
old_text.len(),
|
||||
delta.old_range.clone(),
|
||||
);
|
||||
let mut new_affected = dirty_byte_range_to_line_range(
|
||||
&new_line_starts,
|
||||
output_text.len(),
|
||||
delta.new_range.clone(),
|
||||
);
|
||||
old_affected.start = old_affected.start.saturating_sub(1);
|
||||
old_affected.end = old_affected.end.saturating_add(1).min(old_line_count);
|
||||
new_affected.start = new_affected.start.saturating_sub(1);
|
||||
new_affected.end = new_affected.end.saturating_add(1).min(new_line_count);
|
||||
|
||||
let Some(old_block_ranges) = resolved_output_conflict_block_ranges_in_text(
|
||||
&self.conflict_resolver.marker_segments,
|
||||
&old_text,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Some(new_block_ranges) = resolved_output_conflict_block_ranges_in_text(
|
||||
&self.conflict_resolver.marker_segments,
|
||||
&output_text,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if old_block_ranges.len() != new_block_ranges.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut touched_conflicts: HashSet<usize> = HashSet::default();
|
||||
for (conflict_ix, range) in old_block_ranges.iter().enumerate() {
|
||||
if line_ranges_intersect(range, &old_affected) {
|
||||
touched_conflicts.insert(conflict_ix);
|
||||
}
|
||||
}
|
||||
for (conflict_ix, range) in new_block_ranges.iter().enumerate() {
|
||||
if line_ranges_intersect(range, &new_affected) {
|
||||
touched_conflicts.insert(conflict_ix);
|
||||
}
|
||||
}
|
||||
for conflict_ix in &touched_conflicts {
|
||||
if let Some(old_range) = old_block_ranges.get(*conflict_ix) {
|
||||
old_affected.start = old_affected.start.min(old_range.start);
|
||||
old_affected.end = old_affected.end.max(old_range.end).min(old_line_count);
|
||||
}
|
||||
if let Some(new_range) = new_block_ranges.get(*conflict_ix) {
|
||||
new_affected.start = new_affected.start.min(new_range.start);
|
||||
new_affected.end = new_affected.end.max(new_range.end).min(new_line_count);
|
||||
}
|
||||
}
|
||||
|
||||
let mut recompute_conflicts = Vec::new();
|
||||
for (conflict_ix, new_range) in new_block_ranges.iter().enumerate() {
|
||||
if line_ranges_intersect(new_range, &new_affected) {
|
||||
recompute_conflicts.push(conflict_ix);
|
||||
if let Some(old_range) = old_block_ranges.get(conflict_ix) {
|
||||
old_affected.start = old_affected.start.min(old_range.start);
|
||||
old_affected.end = old_affected.end.max(old_range.end).min(old_line_count);
|
||||
}
|
||||
new_affected.start = new_affected.start.min(new_range.start);
|
||||
new_affected.end = new_affected.end.max(new_range.end).min(new_line_count);
|
||||
}
|
||||
}
|
||||
if old_affected.start != new_affected.start {
|
||||
return false;
|
||||
}
|
||||
|
||||
let view_mode = self.conflict_resolver.view_mode;
|
||||
let mut source_lookup: HashMap<&str, (conflict_resolver::ResolvedLineSource, Option<u32>)> =
|
||||
HashMap::default();
|
||||
match view_mode {
|
||||
ConflictResolverViewMode::ThreeWay => {
|
||||
insert_lookup_from_indexed_text(
|
||||
&mut source_lookup,
|
||||
conflict_resolver::ResolvedLineSource::C,
|
||||
&self.conflict_resolver.three_way_text.theirs,
|
||||
&self.conflict_resolver.three_way_line_starts.theirs,
|
||||
);
|
||||
insert_lookup_from_indexed_text(
|
||||
&mut source_lookup,
|
||||
conflict_resolver::ResolvedLineSource::B,
|
||||
&self.conflict_resolver.three_way_text.ours,
|
||||
&self.conflict_resolver.three_way_line_starts.ours,
|
||||
);
|
||||
insert_lookup_from_indexed_text(
|
||||
&mut source_lookup,
|
||||
conflict_resolver::ResolvedLineSource::A,
|
||||
&self.conflict_resolver.three_way_text.base,
|
||||
&self.conflict_resolver.three_way_line_starts.base,
|
||||
);
|
||||
}
|
||||
ConflictResolverViewMode::TwoWayDiff => {
|
||||
insert_two_way_side_lookup(
|
||||
&mut source_lookup,
|
||||
&self.conflict_resolver.diff_rows,
|
||||
conflict_resolver::ResolvedLineSource::B,
|
||||
|row| row.new.as_deref(),
|
||||
);
|
||||
insert_two_way_side_lookup(
|
||||
&mut source_lookup,
|
||||
&self.conflict_resolver.diff_rows,
|
||||
conflict_resolver::ResolvedLineSource::A,
|
||||
|row| row.old.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let old_meta = self.conflict_resolver.resolved_line_meta.clone();
|
||||
let mut middle_meta = Vec::with_capacity(new_affected.len());
|
||||
for line_ix in new_affected.clone() {
|
||||
let output_line = resolved_output_line_text(&output_text, &new_line_starts, line_ix);
|
||||
let (source, input_line) = source_lookup
|
||||
.get(output_line)
|
||||
.copied()
|
||||
.unwrap_or((conflict_resolver::ResolvedLineSource::Manual, None));
|
||||
middle_meta.push(conflict_resolver::ResolvedLineMeta {
|
||||
output_line: u32::try_from(line_ix).unwrap_or(u32::MAX),
|
||||
source,
|
||||
input_line,
|
||||
});
|
||||
}
|
||||
|
||||
let line_delta = new_affected.len() as isize - old_affected.len() as isize;
|
||||
let mut next_meta = Vec::with_capacity(new_line_count);
|
||||
next_meta.extend(
|
||||
old_meta
|
||||
.iter()
|
||||
.take(old_affected.start.min(old_meta.len()))
|
||||
.cloned(),
|
||||
);
|
||||
next_meta.extend(middle_meta);
|
||||
for entry in old_meta.iter().skip(old_affected.end.min(old_meta.len())) {
|
||||
let mut shifted = entry.clone();
|
||||
shifted.output_line =
|
||||
u32::try_from(shifted_line_index(entry.output_line as usize, line_delta))
|
||||
.unwrap_or(u32::MAX);
|
||||
next_meta.push(shifted);
|
||||
}
|
||||
if next_meta.len() != new_line_count {
|
||||
return false;
|
||||
}
|
||||
apply_conflict_choice_provenance_hints(
|
||||
&mut next_meta,
|
||||
&self.conflict_resolver.marker_segments,
|
||||
&output_text,
|
||||
view_mode,
|
||||
);
|
||||
|
||||
let old_markers = self
|
||||
.conflict_resolver
|
||||
.resolved_output_conflict_markers
|
||||
.clone();
|
||||
let mut next_markers = vec![None; new_line_count];
|
||||
for (line_ix, marker) in old_markers
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.take(old_affected.start.min(old_markers.len()))
|
||||
{
|
||||
if line_ix < new_line_count {
|
||||
next_markers[line_ix] = marker;
|
||||
}
|
||||
}
|
||||
for (old_line_ix, marker) in old_markers
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.skip(old_affected.end.min(old_markers.len()))
|
||||
{
|
||||
let Some(marker) = marker else {
|
||||
continue;
|
||||
};
|
||||
let new_line_ix = shifted_line_index(old_line_ix, line_delta);
|
||||
if new_line_ix < new_line_count {
|
||||
next_markers[new_line_ix] = Some(shift_resolved_output_marker(marker, line_delta));
|
||||
}
|
||||
}
|
||||
let blocks: Vec<&conflict_resolver::ConflictBlock> = self
|
||||
.conflict_resolver
|
||||
.marker_segments
|
||||
.iter()
|
||||
.filter_map(|seg| match seg {
|
||||
conflict_resolver::ConflictSegment::Block(block) => Some(block),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for conflict_ix in recompute_conflicts {
|
||||
let Some(block) = blocks.get(conflict_ix).copied() else {
|
||||
return false;
|
||||
};
|
||||
let Some(range) = new_block_ranges.get(conflict_ix).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let marker_ranges = conflict_marker_ranges_for_block(block, range);
|
||||
write_conflict_markers_for_ranges(
|
||||
&mut next_markers,
|
||||
conflict_ix,
|
||||
!block.resolved,
|
||||
marker_ranges.as_slice(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut next_sources_index = self
|
||||
.conflict_resolver
|
||||
.resolved_output_line_sources_index
|
||||
.clone();
|
||||
update_line_sources_index_for_range(
|
||||
&mut next_sources_index,
|
||||
view_mode,
|
||||
old_meta.as_slice(),
|
||||
&old_text,
|
||||
old_line_starts.as_slice(),
|
||||
old_affected.clone(),
|
||||
false,
|
||||
);
|
||||
update_line_sources_index_for_range(
|
||||
&mut next_sources_index,
|
||||
view_mode,
|
||||
next_meta.as_slice(),
|
||||
&output_text,
|
||||
new_line_starts.as_slice(),
|
||||
new_affected.clone(),
|
||||
true,
|
||||
);
|
||||
|
||||
self.conflict_resolved_preview_syntax_language =
|
||||
path.and_then(rows::diff_syntax_language_for_path);
|
||||
self.conflict_resolved_preview_line_count = new_line_count;
|
||||
self.conflict_resolved_preview_line_starts = new_line_starts;
|
||||
remap_line_keyed_cache_for_delta(
|
||||
&mut self.conflict_resolved_preview_segments_cache,
|
||||
old_affected,
|
||||
new_affected,
|
||||
);
|
||||
let output_syntax_highlights = build_resolved_output_syntax_highlights(
|
||||
self.theme,
|
||||
&output_text,
|
||||
self.conflict_resolved_preview_syntax_language,
|
||||
);
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_highlights(output_syntax_highlights, cx);
|
||||
});
|
||||
self.conflict_resolver.resolved_line_meta = next_meta;
|
||||
self.conflict_resolver.resolved_output_conflict_markers = next_markers;
|
||||
self.conflict_resolver.resolved_output_line_sources_index = next_sources_index;
|
||||
self.conflict_resolved_preview_text = output_text.into();
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn conflict_resolver_scroll_resolved_output_to_line(
|
||||
|
|
@ -535,7 +997,7 @@ impl MainPaneView {
|
|||
target_line_ix: usize,
|
||||
output_text: &str,
|
||||
) {
|
||||
let line_count = output_text.split('\n').count().max(1);
|
||||
let line_count = count_newlines(output_text).saturating_add(1);
|
||||
self.conflict_resolver_scroll_resolved_output_to_line(target_line_ix, line_count);
|
||||
}
|
||||
|
||||
|
|
@ -543,6 +1005,7 @@ impl MainPaneView {
|
|||
&mut self,
|
||||
path: Option<std::path::PathBuf>,
|
||||
output_hash: u64,
|
||||
delta: Option<ResolvedOutlineDelta>,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
self.conflict_resolver.resolver_pending_recompute_seq = self
|
||||
|
|
@ -563,7 +1026,16 @@ impl MainPaneView {
|
|||
{
|
||||
return;
|
||||
}
|
||||
this.recompute_conflict_resolved_outline_and_provenance(path.as_ref(), cx);
|
||||
let did_incremental = delta.is_some_and(|delta| {
|
||||
this.recompute_conflict_resolved_outline_and_provenance_incremental(
|
||||
path.as_ref(),
|
||||
delta,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
if !did_incremental {
|
||||
this.recompute_conflict_resolved_outline_and_provenance(path.as_ref(), cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
|
|
@ -761,6 +1233,55 @@ impl MainPaneView {
|
|||
let context_line =
|
||||
conflict_resolver_output_context_line(&content, cursor_offset, Some(clicked_offset));
|
||||
|
||||
self.open_conflict_resolver_output_context_menu_at_line(
|
||||
context_line,
|
||||
selected_text,
|
||||
content,
|
||||
anchor,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(in crate::view) fn open_conflict_resolver_output_context_menu_for_line(
|
||||
&mut self,
|
||||
line_ix: usize,
|
||||
anchor: Point<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
let content = self
|
||||
.conflict_resolver_input
|
||||
.read_with(cx, |i, _| i.text().to_string());
|
||||
let context_line = line_ix.min(self.conflict_resolved_preview_line_count.saturating_sub(1));
|
||||
let cursor_offset = line_start_offset_for_index(
|
||||
&self.conflict_resolved_preview_line_starts,
|
||||
content.len(),
|
||||
context_line,
|
||||
);
|
||||
self.conflict_resolver_input.update(cx, |input, cx| {
|
||||
input.set_cursor_offset(cursor_offset, cx);
|
||||
});
|
||||
|
||||
self.open_conflict_resolver_output_context_menu_at_line(
|
||||
context_line,
|
||||
None,
|
||||
content,
|
||||
anchor,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_conflict_resolver_output_context_menu_at_line(
|
||||
&mut self,
|
||||
context_line: usize,
|
||||
selected_text: Option<String>,
|
||||
content: String,
|
||||
anchor: Point<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
if let Some(marker) = resolved_output_marker_for_line(
|
||||
&self.conflict_resolver.marker_segments,
|
||||
&content,
|
||||
|
|
@ -795,9 +1316,12 @@ impl MainPaneView {
|
|||
|
||||
let (has_source_a, has_source_b, has_source_c) = if is_three_way {
|
||||
(
|
||||
context_line < self.conflict_resolver.three_way_lines.base.len(),
|
||||
context_line < self.conflict_resolver.three_way_lines.ours.len(),
|
||||
context_line < self.conflict_resolver.three_way_lines.theirs.len(),
|
||||
self.conflict_resolver
|
||||
.three_way_has_line(ThreeWayColumn::Base, context_line),
|
||||
self.conflict_resolver
|
||||
.three_way_has_line(ThreeWayColumn::Ours, context_line),
|
||||
self.conflict_resolver
|
||||
.three_way_has_line(ThreeWayColumn::Theirs, context_line),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
|
|
@ -931,6 +1455,13 @@ impl MainPaneView {
|
|||
self.diff_selection_anchor = None;
|
||||
self.diff_selection_range = None;
|
||||
self.diff_autoscroll_pending = next_diff_target.is_some();
|
||||
self.worktree_preview_path = None;
|
||||
self.worktree_preview = Loadable::NotLoaded;
|
||||
self.worktree_preview_content_rev = 0;
|
||||
self.worktree_preview_segments_cache_path = None;
|
||||
self.worktree_preview_syntax_language = None;
|
||||
self.worktree_preview_segments_cache.clear();
|
||||
self.diff_horizontal_min_width = px(0.0);
|
||||
}
|
||||
|
||||
self.state = next;
|
||||
|
|
@ -1044,6 +1575,9 @@ impl MainPaneView {
|
|||
self.diff_text_segments_cache.resize_with(key + 1, || None);
|
||||
}
|
||||
self.diff_text_segments_cache[key] = Some(value);
|
||||
if self.diff_text_query_segments_cache.len() > key {
|
||||
self.diff_text_query_segments_cache[key] = None;
|
||||
}
|
||||
self.diff_text_segments_cache[key]
|
||||
.as_ref()
|
||||
.expect("just set")
|
||||
|
|
@ -1082,22 +1616,39 @@ impl MainPaneView {
|
|||
false
|
||||
}
|
||||
|
||||
pub(in crate::view) fn diff_visible_len(&self) -> usize {
|
||||
self.diff_visible_inline_map
|
||||
.as_ref()
|
||||
.map(|map| map.visible_len())
|
||||
.unwrap_or_else(|| self.diff_visible_indices.len())
|
||||
}
|
||||
|
||||
pub(in crate::view) fn diff_mapped_ix_for_visible_ix(
|
||||
&self,
|
||||
visible_ix: usize,
|
||||
) -> Option<usize> {
|
||||
if let Some(map) = self.diff_visible_inline_map.as_ref() {
|
||||
return map.src_ix_for_visible_ix(visible_ix);
|
||||
}
|
||||
self.diff_visible_indices.get(visible_ix).copied()
|
||||
}
|
||||
|
||||
pub(super) fn diff_src_ixs_for_visible_ix(&self, visible_ix: usize) -> Vec<usize> {
|
||||
if self.is_file_diff_view_active() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(&mapped_ix) = self.diff_visible_indices.get(visible_ix) else {
|
||||
let Some(mapped_ix) = self.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match self.diff_view {
|
||||
DiffViewMode::Inline => vec![mapped_ix],
|
||||
DiffViewMode::Split => {
|
||||
let Some(row) = self.diff_split_cache.get(mapped_ix) else {
|
||||
let Some(row) = self.patch_diff_split_row(mapped_ix) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match row {
|
||||
PatchSplitRow::Raw { src_ix, .. } => vec![*src_ix],
|
||||
PatchSplitRow::Raw { src_ix, .. } => vec![src_ix],
|
||||
PatchSplitRow::Aligned {
|
||||
old_src_ix,
|
||||
new_src_ix,
|
||||
|
|
@ -1105,12 +1656,12 @@ impl MainPaneView {
|
|||
} => {
|
||||
let mut out = Vec::with_capacity(2);
|
||||
if let Some(ix) = old_src_ix {
|
||||
out.push(*ix);
|
||||
out.push(ix);
|
||||
}
|
||||
if let Some(ix) = new_src_ix
|
||||
&& out.first().copied() != Some(*ix)
|
||||
&& out.first().copied() != Some(ix)
|
||||
{
|
||||
out.push(*ix);
|
||||
out.push(ix);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -1120,7 +1671,19 @@ impl MainPaneView {
|
|||
}
|
||||
|
||||
pub(super) fn diff_enclosing_hunk_src_ix(&self, src_ix: usize) -> Option<usize> {
|
||||
enclosing_hunk_src_ix(&self.diff_cache, src_ix)
|
||||
let src_ix = src_ix.min(self.patch_diff_row_len().saturating_sub(1));
|
||||
for ix in (0..=src_ix).rev() {
|
||||
let line = self.patch_diff_row(ix)?;
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ")
|
||||
{
|
||||
break;
|
||||
}
|
||||
if matches!(line.kind, gitcomet_core::domain::DiffLineKind::Hunk) {
|
||||
return Some(ix);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(in crate::view) fn select_all_diff_text(&mut self) {
|
||||
|
|
@ -1148,7 +1711,7 @@ impl MainPaneView {
|
|||
return;
|
||||
}
|
||||
|
||||
if self.diff_visible_indices.is_empty() {
|
||||
if self.diff_visible_len() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1162,7 +1725,7 @@ impl MainPaneView {
|
|||
.unwrap_or(DiffTextRegion::SplitLeft),
|
||||
};
|
||||
|
||||
let end_visible_ix = self.diff_visible_indices.len() - 1;
|
||||
let end_visible_ix = self.diff_visible_len() - 1;
|
||||
let end_region = start_region;
|
||||
let end_text = self.diff_text_line_for_region(end_visible_ix, end_region);
|
||||
|
||||
|
|
@ -1185,7 +1748,7 @@ impl MainPaneView {
|
|||
end_visible_ix: usize,
|
||||
region: DiffTextRegion,
|
||||
) {
|
||||
let list_len = self.diff_visible_indices.len();
|
||||
let list_len = self.diff_visible_len();
|
||||
if list_len == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1254,7 +1817,7 @@ impl MainPaneView {
|
|||
return;
|
||||
}
|
||||
|
||||
let list_len = self.diff_visible_indices.len();
|
||||
let list_len = self.diff_visible_len();
|
||||
if list_len == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1271,17 +1834,21 @@ impl MainPaneView {
|
|||
DiffClickKind::Line => visible_ix,
|
||||
DiffClickKind::HunkHeader => self
|
||||
.diff_next_boundary_visible_ix(visible_ix, |src_ix| {
|
||||
let line = &self.diff_cache[src_ix];
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Hunk)
|
||||
|| (matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git "))
|
||||
self.patch_diff_row(src_ix).is_some_and(|line| {
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Hunk)
|
||||
|| (matches!(
|
||||
line.kind,
|
||||
gitcomet_core::domain::DiffLineKind::Header
|
||||
) && line.text.starts_with("diff --git "))
|
||||
})
|
||||
})
|
||||
.unwrap_or(list_len - 1),
|
||||
DiffClickKind::FileHeader => self
|
||||
.diff_next_boundary_visible_ix(visible_ix, |src_ix| {
|
||||
let line = &self.diff_cache[src_ix];
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ")
|
||||
self.patch_diff_row(src_ix).is_some_and(|line| {
|
||||
matches!(line.kind, gitcomet_core::domain::DiffLineKind::Header)
|
||||
&& line.text.starts_with("diff --git ")
|
||||
})
|
||||
})
|
||||
.unwrap_or(list_len - 1),
|
||||
},
|
||||
|
|
@ -1320,12 +1887,12 @@ impl MainPaneView {
|
|||
from_visible_ix: usize,
|
||||
is_boundary: impl Fn(&PatchSplitRow) -> bool,
|
||||
) -> Option<usize> {
|
||||
let from_visible_ix =
|
||||
from_visible_ix.min(self.diff_visible_indices.len().saturating_sub(1));
|
||||
for visible_ix in (from_visible_ix + 1)..self.diff_visible_indices.len() {
|
||||
let row_ix = *self.diff_visible_indices.get(visible_ix)?;
|
||||
let row = self.diff_split_cache.get(row_ix)?;
|
||||
if is_boundary(row) {
|
||||
let visible_len = self.diff_visible_len();
|
||||
let from_visible_ix = from_visible_ix.min(visible_len.saturating_sub(1));
|
||||
for visible_ix in (from_visible_ix + 1)..visible_len {
|
||||
let row_ix = self.diff_mapped_ix_for_visible_ix(visible_ix)?;
|
||||
let row = self.patch_diff_split_row(row_ix)?;
|
||||
if is_boundary(&row) {
|
||||
return Some(visible_ix.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
|
@ -1337,10 +1904,10 @@ impl MainPaneView {
|
|||
from_visible_ix: usize,
|
||||
is_boundary: impl Fn(usize) -> bool,
|
||||
) -> Option<usize> {
|
||||
let from_visible_ix =
|
||||
from_visible_ix.min(self.diff_visible_indices.len().saturating_sub(1));
|
||||
for visible_ix in (from_visible_ix + 1)..self.diff_visible_indices.len() {
|
||||
let src_ix = *self.diff_visible_indices.get(visible_ix)?;
|
||||
let visible_len = self.diff_visible_len();
|
||||
let from_visible_ix = from_visible_ix.min(visible_len.saturating_sub(1));
|
||||
for visible_ix in (from_visible_ix + 1)..visible_len {
|
||||
let src_ix = self.diff_mapped_ix_for_visible_ix(visible_ix)?;
|
||||
if is_boundary(src_ix) {
|
||||
return Some(visible_ix.saturating_sub(1));
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -68,9 +68,21 @@ impl MainPaneView {
|
|||
diff_mode: self.conflict_resolver.diff_mode,
|
||||
marker_segments: &self.conflict_resolver.marker_segments,
|
||||
three_way_visible_map: &self.conflict_resolver.three_way_visible_map,
|
||||
three_way_base_lines: &self.conflict_resolver.three_way_lines.base,
|
||||
three_way_ours_lines: &self.conflict_resolver.three_way_lines.ours,
|
||||
three_way_theirs_lines: &self.conflict_resolver.three_way_lines.theirs,
|
||||
three_way_base_text: &self.conflict_resolver.three_way_text.base,
|
||||
three_way_base_line_starts: &self
|
||||
.conflict_resolver
|
||||
.three_way_line_starts
|
||||
.base,
|
||||
three_way_ours_text: &self.conflict_resolver.three_way_text.ours,
|
||||
three_way_ours_line_starts: &self
|
||||
.conflict_resolver
|
||||
.three_way_line_starts
|
||||
.ours,
|
||||
three_way_theirs_text: &self.conflict_resolver.three_way_text.theirs,
|
||||
three_way_theirs_line_starts: &self
|
||||
.conflict_resolver
|
||||
.three_way_line_starts
|
||||
.theirs,
|
||||
diff_visible_row_indices: &self.conflict_resolver.diff_visible_row_indices,
|
||||
inline_visible_row_indices: &self
|
||||
.conflict_resolver
|
||||
|
|
@ -104,7 +116,7 @@ impl MainPaneView {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
let total = self.diff_visible_indices.len();
|
||||
let total = self.diff_visible_len();
|
||||
for visible_ix in 0..total {
|
||||
match self.diff_view {
|
||||
DiffViewMode::Inline => {
|
||||
|
|
@ -236,9 +248,12 @@ struct ConflictResolverSearchContext<'a> {
|
|||
diff_mode: ConflictDiffMode,
|
||||
marker_segments: &'a [conflict_resolver::ConflictSegment],
|
||||
three_way_visible_map: &'a [conflict_resolver::ThreeWayVisibleItem],
|
||||
three_way_base_lines: &'a [gpui::SharedString],
|
||||
three_way_ours_lines: &'a [gpui::SharedString],
|
||||
three_way_theirs_lines: &'a [gpui::SharedString],
|
||||
three_way_base_text: &'a str,
|
||||
three_way_base_line_starts: &'a [usize],
|
||||
three_way_ours_text: &'a str,
|
||||
three_way_ours_line_starts: &'a [usize],
|
||||
three_way_theirs_text: &'a str,
|
||||
three_way_theirs_line_starts: &'a [usize],
|
||||
diff_visible_row_indices: &'a [usize],
|
||||
inline_visible_row_indices: &'a [usize],
|
||||
diff_rows: &'a [gitcomet_core::file_diff::FileDiffRow],
|
||||
|
|
@ -319,23 +334,35 @@ fn three_way_visible_item_matches_query(
|
|||
ctx: &ConflictResolverSearchContext<'_>,
|
||||
query: &str,
|
||||
) -> bool {
|
||||
fn line_text<'a>(text: &'a str, line_starts: &[usize], line_ix: usize) -> &'a str {
|
||||
if text.is_empty() {
|
||||
return "";
|
||||
}
|
||||
let text_len = text.len();
|
||||
let start = line_starts.get(line_ix).copied().unwrap_or(text_len);
|
||||
if start >= text_len {
|
||||
return "";
|
||||
}
|
||||
let mut end = line_starts
|
||||
.get(line_ix.saturating_add(1))
|
||||
.copied()
|
||||
.unwrap_or(text_len)
|
||||
.min(text_len);
|
||||
if end > start && text.as_bytes().get(end.saturating_sub(1)) == Some(&b'\n') {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
text.get(start..end).unwrap_or("")
|
||||
}
|
||||
|
||||
match item {
|
||||
conflict_resolver::ThreeWayVisibleItem::Line(ix) => {
|
||||
let base = ctx
|
||||
.three_way_base_lines
|
||||
.get(ix)
|
||||
.map(|s| s.as_ref())
|
||||
.unwrap_or("");
|
||||
let ours = ctx
|
||||
.three_way_ours_lines
|
||||
.get(ix)
|
||||
.map(|s| s.as_ref())
|
||||
.unwrap_or("");
|
||||
let theirs = ctx
|
||||
.three_way_theirs_lines
|
||||
.get(ix)
|
||||
.map(|s| s.as_ref())
|
||||
.unwrap_or("");
|
||||
let base = line_text(ctx.three_way_base_text, ctx.three_way_base_line_starts, ix);
|
||||
let ours = line_text(ctx.three_way_ours_text, ctx.three_way_ours_line_starts, ix);
|
||||
let theirs = line_text(
|
||||
ctx.three_way_theirs_text,
|
||||
ctx.three_way_theirs_line_starts,
|
||||
ix,
|
||||
);
|
||||
|
||||
contains_ascii_case_insensitive(base, query)
|
||||
|| contains_ascii_case_insensitive(ours, query)
|
||||
|
|
@ -363,7 +390,6 @@ mod tests {
|
|||
};
|
||||
use gitcomet_core::domain::DiffLineKind;
|
||||
use gitcomet_core::file_diff::{FileDiffRow, FileDiffRowKind};
|
||||
use gpui::SharedString;
|
||||
|
||||
#[test]
|
||||
fn matches_empty_needle() {
|
||||
|
|
@ -407,18 +433,24 @@ mod tests {
|
|||
content: "inline-only".into(),
|
||||
}];
|
||||
let three_way_visible_map = vec![ThreeWayVisibleItem::Line(0)];
|
||||
let three_way_base_lines = vec![SharedString::from("base text")];
|
||||
let three_way_ours_lines = vec![SharedString::from("needle")];
|
||||
let three_way_theirs_lines = vec![SharedString::from("remote text")];
|
||||
let three_way_base_text = "base text\n";
|
||||
let three_way_ours_text = "needle\n";
|
||||
let three_way_theirs_text = "remote text\n";
|
||||
let three_way_base_line_starts = vec![0];
|
||||
let three_way_ours_line_starts = vec![0];
|
||||
let three_way_theirs_line_starts = vec![0];
|
||||
|
||||
let three_way_ctx = ConflictResolverSearchContext {
|
||||
view_mode: ConflictResolverViewMode::ThreeWay,
|
||||
diff_mode: ConflictDiffMode::Split,
|
||||
marker_segments: &marker_segments,
|
||||
three_way_visible_map: &three_way_visible_map,
|
||||
three_way_base_lines: &three_way_base_lines,
|
||||
three_way_ours_lines: &three_way_ours_lines,
|
||||
three_way_theirs_lines: &three_way_theirs_lines,
|
||||
three_way_base_text,
|
||||
three_way_base_line_starts: &three_way_base_line_starts,
|
||||
three_way_ours_text,
|
||||
three_way_ours_line_starts: &three_way_ours_line_starts,
|
||||
three_way_theirs_text,
|
||||
three_way_theirs_line_starts: &three_way_theirs_line_starts,
|
||||
diff_visible_row_indices: &[0],
|
||||
inline_visible_row_indices: &[0],
|
||||
diff_rows: &diff_rows,
|
||||
|
|
@ -439,9 +471,12 @@ mod tests {
|
|||
diff_mode: ConflictDiffMode::Split,
|
||||
marker_segments: &marker_segments,
|
||||
three_way_visible_map: &three_way_visible_map,
|
||||
three_way_base_lines: &three_way_base_lines,
|
||||
three_way_ours_lines: &three_way_ours_lines,
|
||||
three_way_theirs_lines: &three_way_theirs_lines,
|
||||
three_way_base_text,
|
||||
three_way_base_line_starts: &three_way_base_line_starts,
|
||||
three_way_ours_text,
|
||||
three_way_ours_line_starts: &three_way_ours_line_starts,
|
||||
three_way_theirs_text,
|
||||
three_way_theirs_line_starts: &three_way_theirs_line_starts,
|
||||
diff_visible_row_indices: &[0],
|
||||
inline_visible_row_indices: &[0],
|
||||
diff_rows: &diff_rows,
|
||||
|
|
@ -463,16 +498,18 @@ mod tests {
|
|||
resolved: true,
|
||||
})];
|
||||
let three_way_visible_map = vec![ThreeWayVisibleItem::CollapsedBlock(0)];
|
||||
let empty_lines: Vec<SharedString> = Vec::new();
|
||||
|
||||
let ctx = ConflictResolverSearchContext {
|
||||
view_mode: ConflictResolverViewMode::ThreeWay,
|
||||
diff_mode: ConflictDiffMode::Split,
|
||||
marker_segments: &marker_segments,
|
||||
three_way_visible_map: &three_way_visible_map,
|
||||
three_way_base_lines: &empty_lines,
|
||||
three_way_ours_lines: &empty_lines,
|
||||
three_way_theirs_lines: &empty_lines,
|
||||
three_way_base_text: "",
|
||||
three_way_base_line_starts: &[],
|
||||
three_way_ours_text: "",
|
||||
three_way_ours_line_starts: &[],
|
||||
three_way_theirs_text: "",
|
||||
three_way_theirs_line_starts: &[],
|
||||
diff_visible_row_indices: &[],
|
||||
inline_visible_row_indices: &[],
|
||||
diff_rows: &[],
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ impl MainPaneView {
|
|||
.unwrap_or(fallback);
|
||||
}
|
||||
|
||||
let Some(&mapped_ix) = self.diff_visible_indices.get(visible_ix) else {
|
||||
let Some(mapped_ix) = self.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ impl MainPaneView {
|
|||
if let Some(styled) = self.diff_text_segments_cache_get(mapped_ix) {
|
||||
return styled.text.clone();
|
||||
}
|
||||
let Some(line) = self.diff_cache.get(mapped_ix) else {
|
||||
let Some(line) = self.patch_diff_row(mapped_ix) else {
|
||||
return fallback;
|
||||
};
|
||||
let click_kind = self
|
||||
|
|
@ -346,18 +346,18 @@ impl MainPaneView {
|
|||
return expand_tabs(text);
|
||||
}
|
||||
|
||||
let Some(split_row) = self.diff_split_cache.get(mapped_ix) else {
|
||||
let Some(split_row) = self.patch_diff_split_row(mapped_ix) else {
|
||||
return fallback;
|
||||
};
|
||||
match split_row {
|
||||
PatchSplitRow::Raw { src_ix, click_kind } => {
|
||||
let Some(line) = self.diff_cache.get(*src_ix) else {
|
||||
let Some(line) = self.patch_diff_row(src_ix) else {
|
||||
return fallback;
|
||||
};
|
||||
if matches!(
|
||||
click_kind,
|
||||
DiffClickKind::HunkHeader | DiffClickKind::FileHeader
|
||||
) && let Some(display) = self.diff_header_display_cache.get(src_ix)
|
||||
) && let Some(display) = self.diff_header_display_cache.get(&src_ix)
|
||||
{
|
||||
return display.clone();
|
||||
}
|
||||
|
|
@ -513,7 +513,7 @@ impl MainPaneView {
|
|||
let list_len = if is_file_preview {
|
||||
self.worktree_preview_line_count().unwrap_or(0)
|
||||
} else {
|
||||
self.diff_visible_indices.len()
|
||||
self.diff_visible_len()
|
||||
};
|
||||
let clicked_visible_ix = if list_len == 0 {
|
||||
visible_ix
|
||||
|
|
@ -568,7 +568,10 @@ impl MainPaneView {
|
|||
let mut context_by_old_line: HashMap<u32, usize> =
|
||||
HashMap::with_capacity_and_hasher(approx_map_len, Default::default());
|
||||
|
||||
for (ix, line) in self.diff_cache.iter().enumerate() {
|
||||
for ix in 0..self.patch_diff_row_len() {
|
||||
let Some(line) = self.patch_diff_row(ix) else {
|
||||
continue;
|
||||
};
|
||||
if self.diff_file_for_src_ix.get(ix).and_then(|p| p.as_deref())
|
||||
!= rel_str.as_deref()
|
||||
{
|
||||
|
|
@ -608,7 +611,7 @@ impl MainPaneView {
|
|||
|
||||
let src_ixs_for_visible_ix = |visible_ix: usize| -> Vec<usize> {
|
||||
if let Some(lookup) = file_diff_lookup.as_ref() {
|
||||
let Some(&mapped_ix) = self.diff_visible_indices.get(visible_ix) else {
|
||||
let Some(mapped_ix) = self.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match self.diff_view {
|
||||
|
|
@ -724,7 +727,7 @@ impl MainPaneView {
|
|||
|
||||
for vix in sel_a..=sel_b {
|
||||
for src_ix in src_ixs_for_visible_ix(vix) {
|
||||
let Some(line) = self.diff_cache.get(src_ix) else {
|
||||
let Some(line) = self.patch_diff_row(src_ix) else {
|
||||
continue;
|
||||
};
|
||||
selected_src_ixs.insert(src_ix);
|
||||
|
|
@ -745,19 +748,20 @@ impl MainPaneView {
|
|||
selected_hunks.sort_unstable();
|
||||
selected_hunks.dedup();
|
||||
|
||||
let hunk_patch = build_unified_patch_for_hunks(&self.diff_cache, &selected_hunks);
|
||||
let materialized_diff = self.patch_diff_rows_slice(0, self.patch_diff_row_len());
|
||||
let hunk_patch = build_unified_patch_for_hunks(&materialized_diff, &selected_hunks);
|
||||
let hunks_count = hunk_patch
|
||||
.as_ref()
|
||||
.map(|_| selected_hunks.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let lines_patch = build_unified_patch_for_selected_lines_across_hunks(
|
||||
&self.diff_cache,
|
||||
&materialized_diff,
|
||||
&selected_change_src_ixs,
|
||||
);
|
||||
let discard_lines_patch = if area == DiffArea::Unstaged {
|
||||
build_unified_patch_for_selected_lines_across_hunks_for_worktree_discard(
|
||||
&self.diff_cache,
|
||||
&materialized_diff,
|
||||
&selected_change_src_ixs,
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,33 +1,15 @@
|
|||
use super::*;
|
||||
|
||||
/// Extract unique source lines from two-way diff rows for provenance matching.
|
||||
///
|
||||
/// Returns (old_lines, new_lines) as `Vec<SharedString>` suitable for `SourceLines`.
|
||||
pub(super) fn collect_two_way_source_lines(
|
||||
diff_rows: &[FileDiffRow],
|
||||
) -> (Vec<SharedString>, Vec<SharedString>) {
|
||||
let mut old_lines = Vec::with_capacity(diff_rows.len());
|
||||
let mut new_lines = Vec::with_capacity(diff_rows.len());
|
||||
for row in diff_rows {
|
||||
if let Some(ref text) = row.old {
|
||||
old_lines.push(SharedString::from(text.clone()));
|
||||
}
|
||||
if let Some(ref text) = row.new {
|
||||
new_lines.push(SharedString::from(text.clone()));
|
||||
}
|
||||
}
|
||||
(old_lines, new_lines)
|
||||
}
|
||||
|
||||
pub(super) fn build_resolved_output_syntax_highlights(
|
||||
theme: AppTheme,
|
||||
lines: &[String],
|
||||
output_text: &str,
|
||||
language: Option<rows::DiffSyntaxLanguage>,
|
||||
) -> Vec<(Range<usize>, gpui::HighlightStyle)> {
|
||||
let Some(language) = language else {
|
||||
return Vec::new();
|
||||
};
|
||||
let syntax_mode = if lines.len() <= CONFLICT_RESOLVED_OUTLINE_AUTO_SYNTAX_MAX_LINES {
|
||||
let line_count = count_newlines(output_text).saturating_add(1);
|
||||
let syntax_mode = if line_count <= CONFLICT_RESOLVED_OUTLINE_AUTO_SYNTAX_MAX_LINES {
|
||||
rows::DiffSyntaxMode::Auto
|
||||
} else {
|
||||
rows::DiffSyntaxMode::HeuristicOnly
|
||||
|
|
@ -35,7 +17,7 @@ pub(super) fn build_resolved_output_syntax_highlights(
|
|||
|
||||
let mut highlights = Vec::new();
|
||||
let mut line_offset = 0usize;
|
||||
for (line_ix, line) in lines.iter().enumerate() {
|
||||
for (line_ix, line) in output_text.split('\n').enumerate() {
|
||||
for (range, style) in rows::syntax_highlights_for_line(theme, line, language, syntax_mode) {
|
||||
highlights.push((
|
||||
(line_offset + range.start)..(line_offset + range.end),
|
||||
|
|
@ -43,7 +25,7 @@ pub(super) fn build_resolved_output_syntax_highlights(
|
|||
));
|
||||
}
|
||||
line_offset += line.len();
|
||||
if line_ix + 1 < lines.len() {
|
||||
if line_ix + 1 < line_count {
|
||||
line_offset += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +44,25 @@ pub(super) fn count_newlines(text: &str) -> usize {
|
|||
text.as_bytes().iter().filter(|&&b| b == b'\n').count()
|
||||
}
|
||||
|
||||
pub(super) fn build_line_starts(text: &str) -> Vec<usize> {
|
||||
let mut line_starts = Vec::with_capacity(count_newlines(text).saturating_add(1));
|
||||
line_starts.push(0usize);
|
||||
for (ix, byte) in text.as_bytes().iter().enumerate() {
|
||||
if *byte == b'\n' {
|
||||
line_starts.push(ix.saturating_add(1));
|
||||
}
|
||||
}
|
||||
line_starts
|
||||
}
|
||||
|
||||
pub(super) fn line_start_offset_for_index(
|
||||
line_starts: &[usize],
|
||||
text_len: usize,
|
||||
line_ix: usize,
|
||||
) -> usize {
|
||||
line_starts.get(line_ix).copied().unwrap_or(text_len)
|
||||
}
|
||||
|
||||
pub(super) fn source_line_count(text: &str) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
|
|
@ -70,11 +71,157 @@ pub(super) fn source_line_count(text: &str) -> usize {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn output_line_range_for_conflict_block_in_text(
|
||||
segments: &[conflict_resolver::ConflictSegment],
|
||||
output_text: &str,
|
||||
conflict_ix: usize,
|
||||
/// Number of logical rows produced by `split('\n')` (always at least 1).
|
||||
pub(super) fn split_line_count(text: &str) -> usize {
|
||||
count_newlines(text).saturating_add(1)
|
||||
}
|
||||
|
||||
/// Byte range of line content at `line_ix` (without trailing newline).
|
||||
///
|
||||
/// Uses `split('\n')` row semantics, so trailing newline creates a final empty row.
|
||||
pub(super) fn line_content_byte_range_for_index(
|
||||
text: &str,
|
||||
line_ix: usize,
|
||||
) -> Option<Range<usize>> {
|
||||
let line_count = split_line_count(text);
|
||||
if line_ix >= line_count {
|
||||
return None;
|
||||
}
|
||||
let line_starts = build_line_starts(text);
|
||||
let text_len = text.len();
|
||||
let start = line_starts.get(line_ix).copied().unwrap_or(text_len);
|
||||
let mut end = line_starts
|
||||
.get(line_ix.saturating_add(1))
|
||||
.copied()
|
||||
.unwrap_or(text_len)
|
||||
.min(text_len);
|
||||
if end > start && text.as_bytes().get(end.saturating_sub(1)) == Some(&b'\n') {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
Some(start..end)
|
||||
}
|
||||
|
||||
/// Build insertion text for appending one logical line to output.
|
||||
pub(super) fn append_line_insertion_text(existing: &str, line: &str) -> String {
|
||||
let needs_leading_newline = !existing.is_empty() && !existing.ends_with('\n');
|
||||
let mut out = String::with_capacity(
|
||||
line.len()
|
||||
.saturating_add(1)
|
||||
.saturating_add(usize::from(needs_leading_newline)),
|
||||
);
|
||||
if needs_leading_newline {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(super) struct ResolvedOutlineDelta {
|
||||
pub(super) old_range: Range<usize>,
|
||||
pub(super) new_range: Range<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn resolved_outline_delta_between_texts(
|
||||
old_text: &str,
|
||||
new_text: &str,
|
||||
) -> Option<ResolvedOutlineDelta> {
|
||||
if old_text == new_text {
|
||||
return None;
|
||||
}
|
||||
|
||||
let old = old_text.as_bytes();
|
||||
let new = new_text.as_bytes();
|
||||
let old_len = old.len();
|
||||
let new_len = new.len();
|
||||
|
||||
let mut prefix = 0usize;
|
||||
let prefix_max = old_len.min(new_len);
|
||||
while prefix < prefix_max && old[prefix] == new[prefix] {
|
||||
prefix = prefix.saturating_add(1);
|
||||
}
|
||||
while prefix > 0 && (!old_text.is_char_boundary(prefix) || !new_text.is_char_boundary(prefix)) {
|
||||
prefix = prefix.saturating_sub(1);
|
||||
}
|
||||
|
||||
let mut suffix = 0usize;
|
||||
while suffix < old_len.saturating_sub(prefix)
|
||||
&& suffix < new_len.saturating_sub(prefix)
|
||||
&& old[old_len.saturating_sub(1 + suffix)] == new[new_len.saturating_sub(1 + suffix)]
|
||||
{
|
||||
suffix = suffix.saturating_add(1);
|
||||
}
|
||||
while suffix > 0
|
||||
&& (!old_text.is_char_boundary(old_len.saturating_sub(suffix))
|
||||
|| !new_text.is_char_boundary(new_len.saturating_sub(suffix)))
|
||||
{
|
||||
suffix = suffix.saturating_sub(1);
|
||||
}
|
||||
|
||||
Some(ResolvedOutlineDelta {
|
||||
old_range: prefix..old_len.saturating_sub(suffix),
|
||||
new_range: prefix..new_len.saturating_sub(suffix),
|
||||
})
|
||||
}
|
||||
|
||||
fn line_index_for_byte_offset(line_starts: &[usize], byte_offset: usize) -> usize {
|
||||
if line_starts.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
line_starts
|
||||
.partition_point(|&start| start <= byte_offset)
|
||||
.saturating_sub(1)
|
||||
}
|
||||
|
||||
pub(super) fn dirty_byte_range_to_line_range(
|
||||
line_starts: &[usize],
|
||||
text_len: usize,
|
||||
dirty_range: Range<usize>,
|
||||
) -> Range<usize> {
|
||||
let line_count = line_starts.len().max(1);
|
||||
let start_byte = dirty_range.start.min(text_len);
|
||||
let end_byte = dirty_range.end.min(text_len);
|
||||
let start_line = line_index_for_byte_offset(line_starts, start_byte).min(line_count - 1);
|
||||
let end_line_exclusive = if dirty_range.is_empty() {
|
||||
start_line.saturating_add(1)
|
||||
} else {
|
||||
line_index_for_byte_offset(line_starts, end_byte).saturating_add(1)
|
||||
}
|
||||
.clamp(start_line.saturating_add(1), line_count);
|
||||
start_line..end_line_exclusive
|
||||
}
|
||||
|
||||
pub(super) fn shifted_line_index(ix: usize, delta: isize) -> usize {
|
||||
if delta >= 0 {
|
||||
ix.saturating_add(delta as usize)
|
||||
} else {
|
||||
ix.saturating_sub((-delta) as usize)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remap_line_keyed_cache_for_delta<T>(
|
||||
cache: &mut HashMap<usize, T>,
|
||||
old_range: Range<usize>,
|
||||
new_range: Range<usize>,
|
||||
) {
|
||||
let shift = new_range.len() as isize - old_range.len() as isize;
|
||||
let previous = std::mem::take(cache);
|
||||
for (line_ix, value) in previous {
|
||||
if line_ix < old_range.start {
|
||||
cache.insert(line_ix, value);
|
||||
continue;
|
||||
}
|
||||
if line_ix >= old_range.end {
|
||||
cache.insert(shifted_line_index(line_ix, shift), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolved_output_conflict_block_ranges_in_text(
|
||||
marker_segments: &[conflict_resolver::ConflictSegment],
|
||||
output_text: &str,
|
||||
) -> Option<Vec<Range<usize>>> {
|
||||
fn is_line_boundary(text: &str, byte_ix: usize) -> bool {
|
||||
if byte_ix == 0 || byte_ix == text.len() {
|
||||
return true;
|
||||
|
|
@ -84,11 +231,10 @@ pub(super) fn output_line_range_for_conflict_block_in_text(
|
|||
.is_some_and(|b| *b == b'\n')
|
||||
}
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
let mut line_offset = 0usize;
|
||||
let mut block_ix = 0usize;
|
||||
|
||||
for seg in segments {
|
||||
for seg in marker_segments {
|
||||
match seg {
|
||||
conflict_resolver::ConflictSegment::Text(text) => {
|
||||
let tail = output_text.get(cursor..)?;
|
||||
|
|
@ -113,26 +259,99 @@ pub(super) fn output_line_range_for_conflict_block_in_text(
|
|||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = expected.as_str();
|
||||
let start_line = line_offset;
|
||||
let mut end_line = line_offset.saturating_add(count_newlines(content));
|
||||
if end == output_text.len() && !content.is_empty() {
|
||||
let mut end_line = line_offset.saturating_add(count_newlines(&expected));
|
||||
if end == output_text.len() && !expected.is_empty() {
|
||||
end_line = end_line.saturating_add(1);
|
||||
}
|
||||
|
||||
if block_ix == conflict_ix {
|
||||
return Some(start_line..end_line);
|
||||
}
|
||||
|
||||
line_offset = line_offset.saturating_add(count_newlines(content));
|
||||
ranges.push(start_line..end_line);
|
||||
line_offset = line_offset.saturating_add(count_newlines(&expected));
|
||||
cursor = end;
|
||||
block_ix = block_ix.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
Some(ranges)
|
||||
}
|
||||
|
||||
pub(super) fn conflict_marker_ranges_for_block(
|
||||
block: &conflict_resolver::ConflictBlock,
|
||||
line_range: Range<usize>,
|
||||
) -> Vec<Range<usize>> {
|
||||
let mut marker_ranges = Vec::new();
|
||||
if !block.resolved
|
||||
&& let Some(relative_subranges) = unresolved_decision_ranges_for_block(block)
|
||||
.or_else(|| unresolved_subchunk_conflict_ranges_for_block(block))
|
||||
{
|
||||
for relative in relative_subranges {
|
||||
let start = line_range
|
||||
.start
|
||||
.saturating_add(relative.start)
|
||||
.min(line_range.end);
|
||||
let end = line_range
|
||||
.start
|
||||
.saturating_add(relative.end)
|
||||
.min(line_range.end);
|
||||
marker_ranges.push(start..end);
|
||||
}
|
||||
}
|
||||
if marker_ranges.is_empty() {
|
||||
marker_ranges.push(line_range);
|
||||
}
|
||||
marker_ranges
|
||||
}
|
||||
|
||||
pub(super) fn write_conflict_markers_for_ranges(
|
||||
markers: &mut [Option<ResolvedOutputConflictMarker>],
|
||||
conflict_ix: usize,
|
||||
unresolved: bool,
|
||||
marker_ranges: &[Range<usize>],
|
||||
) {
|
||||
let output_line_count = markers.len();
|
||||
if output_line_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
for marker_range in marker_ranges {
|
||||
if marker_range.start < marker_range.end {
|
||||
let end = marker_range.end.min(output_line_count);
|
||||
for (line_ix, marker_slot) in markers
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.take(end)
|
||||
.skip(marker_range.start)
|
||||
{
|
||||
*marker_slot = Some(ResolvedOutputConflictMarker {
|
||||
conflict_ix,
|
||||
range_start: marker_range.start,
|
||||
range_end: marker_range.end,
|
||||
is_start: line_ix == marker_range.start,
|
||||
is_end: line_ix + 1 == marker_range.end,
|
||||
unresolved,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let anchor = marker_range.start.min(output_line_count.saturating_sub(1));
|
||||
markers[anchor] = Some(ResolvedOutputConflictMarker {
|
||||
conflict_ix,
|
||||
range_start: marker_range.start,
|
||||
range_end: marker_range.end,
|
||||
is_start: true,
|
||||
is_end: true,
|
||||
unresolved,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn output_line_range_for_conflict_block_in_text(
|
||||
segments: &[conflict_resolver::ConflictSegment],
|
||||
output_text: &str,
|
||||
conflict_ix: usize,
|
||||
) -> Option<Range<usize>> {
|
||||
resolved_output_conflict_block_ranges_in_text(segments, output_text)
|
||||
.and_then(|ranges| ranges.get(conflict_ix).cloned())
|
||||
}
|
||||
|
||||
pub(super) fn conflict_fragment_text_for_choice(
|
||||
|
|
@ -338,63 +557,28 @@ pub(super) fn build_resolved_output_conflict_markers(
|
|||
return markers;
|
||||
}
|
||||
|
||||
let mut conflict_ix = 0usize;
|
||||
for seg in marker_segments {
|
||||
let conflict_resolver::ConflictSegment::Block(block) = seg else {
|
||||
continue;
|
||||
};
|
||||
let unresolved = !block.resolved;
|
||||
let Some(block_ranges) =
|
||||
resolved_output_conflict_block_ranges_in_text(marker_segments, output_text)
|
||||
else {
|
||||
return markers;
|
||||
};
|
||||
|
||||
if let Some(range) =
|
||||
output_line_range_for_conflict_block_in_text(marker_segments, output_text, conflict_ix)
|
||||
{
|
||||
let mut marker_ranges: Vec<Range<usize>> = Vec::new();
|
||||
if unresolved
|
||||
&& let Some(relative_subranges) = unresolved_decision_ranges_for_block(block)
|
||||
.or_else(|| unresolved_subchunk_conflict_ranges_for_block(block))
|
||||
{
|
||||
for relative in relative_subranges {
|
||||
let start = range.start.saturating_add(relative.start).min(range.end);
|
||||
let end = range.start.saturating_add(relative.end).min(range.end);
|
||||
marker_ranges.push(start..end);
|
||||
}
|
||||
}
|
||||
if marker_ranges.is_empty() {
|
||||
marker_ranges.push(range);
|
||||
}
|
||||
|
||||
for marker_range in marker_ranges {
|
||||
if marker_range.start < marker_range.end {
|
||||
let end = marker_range.end.min(output_line_count);
|
||||
for (line_ix, marker_slot) in markers
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.take(end)
|
||||
.skip(marker_range.start)
|
||||
{
|
||||
*marker_slot = Some(ResolvedOutputConflictMarker {
|
||||
conflict_ix,
|
||||
range_start: marker_range.start,
|
||||
range_end: marker_range.end,
|
||||
is_start: line_ix == marker_range.start,
|
||||
is_end: line_ix + 1 == marker_range.end,
|
||||
unresolved,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let anchor = marker_range.start.min(output_line_count.saturating_sub(1));
|
||||
markers[anchor] = Some(ResolvedOutputConflictMarker {
|
||||
conflict_ix,
|
||||
range_start: marker_range.start,
|
||||
range_end: marker_range.end,
|
||||
is_start: true,
|
||||
is_end: true,
|
||||
unresolved,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
conflict_ix = conflict_ix.saturating_add(1);
|
||||
for (conflict_ix, (block, range)) in marker_segments
|
||||
.iter()
|
||||
.filter_map(|seg| match seg {
|
||||
conflict_resolver::ConflictSegment::Block(block) => Some(block),
|
||||
_ => None,
|
||||
})
|
||||
.zip(block_ranges.into_iter())
|
||||
.enumerate()
|
||||
{
|
||||
let marker_ranges = conflict_marker_ranges_for_block(block, range);
|
||||
write_conflict_markers_for_ranges(
|
||||
&mut markers,
|
||||
conflict_ix,
|
||||
!block.resolved,
|
||||
marker_ranges.as_slice(),
|
||||
);
|
||||
}
|
||||
|
||||
markers
|
||||
|
|
@ -1434,6 +1618,22 @@ pub(super) fn focused_mergetool_save_exit_code(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(in crate::view) enum PreparedSyntaxViewMode {
|
||||
FileDiffInline,
|
||||
FileDiffSplitLeft,
|
||||
FileDiffSplitRight,
|
||||
WorktreePreview,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub(in crate::view) struct PreparedSyntaxDocumentKey {
|
||||
pub(in crate::view) repo_id: RepoId,
|
||||
pub(in crate::view) target_rev: u64,
|
||||
pub(in crate::view) file_path: std::path::PathBuf,
|
||||
pub(in crate::view) view_mode: PreparedSyntaxViewMode,
|
||||
}
|
||||
|
||||
pub(in crate::view) struct MainPaneView {
|
||||
pub(in crate::view) store: Arc<AppStore>,
|
||||
pub(super) state: Arc<AppState>,
|
||||
|
|
@ -1462,9 +1662,14 @@ pub(in crate::view) struct MainPaneView {
|
|||
pub(in crate::view) diff_cache_rev: u64,
|
||||
pub(in crate::view) diff_cache_target: Option<DiffTarget>,
|
||||
pub(in crate::view) diff_cache: Vec<AnnotatedDiffLine>,
|
||||
pub(in crate::view) diff_row_provider: Option<Arc<super::diff_cache::PagedPatchDiffRows>>,
|
||||
pub(in crate::view) diff_split_row_provider:
|
||||
Option<Arc<super::diff_cache::PagedPatchSplitRows>>,
|
||||
pub(in crate::view) diff_file_for_src_ix: Vec<Option<Arc<str>>>,
|
||||
pub(in crate::view) diff_language_for_src_ix: Vec<Option<rows::DiffSyntaxLanguage>>,
|
||||
pub(in crate::view) diff_click_kinds: Vec<DiffClickKind>,
|
||||
pub(in crate::view) diff_line_kind_for_src_ix: Vec<gitcomet_core::domain::DiffLineKind>,
|
||||
pub(in crate::view) diff_hide_unified_header_for_src_ix: Vec<bool>,
|
||||
pub(in crate::view) diff_header_display_cache: HashMap<usize, SharedString>,
|
||||
pub(in crate::view) diff_split_cache: Vec<PatchSplitRow>,
|
||||
pub(in crate::view) diff_split_cache_len: usize,
|
||||
|
|
@ -1472,15 +1677,17 @@ pub(in crate::view) struct MainPaneView {
|
|||
pub(in crate::view) diff_autoscroll_pending: bool,
|
||||
pub(in crate::view) diff_raw_input: Entity<components::TextInput>,
|
||||
pub(in crate::view) diff_visible_indices: Vec<usize>,
|
||||
pub(in crate::view) diff_visible_inline_map: Option<super::diff_cache::PatchInlineVisibleMap>,
|
||||
pub(in crate::view) diff_visible_cache_len: usize,
|
||||
pub(in crate::view) diff_visible_view: DiffViewMode,
|
||||
pub(in crate::view) diff_visible_is_file_view: bool,
|
||||
pub(in crate::view) diff_scrollbar_markers_cache: Vec<components::ScrollbarMarker>,
|
||||
pub(in crate::view) diff_word_highlights: Vec<Option<Vec<Range<usize>>>>,
|
||||
pub(in crate::view) diff_word_highlights_seq: u64,
|
||||
pub(in crate::view) diff_word_highlights_inflight: Option<u64>,
|
||||
pub(in crate::view) diff_file_stats: Vec<Option<(usize, usize)>>,
|
||||
pub(in crate::view) diff_text_segments_cache: Vec<Option<CachedDiffStyledText>>,
|
||||
pub(in crate::view) diff_text_query_segments_cache: Vec<Option<CachedDiffStyledText>>,
|
||||
pub(in crate::view) diff_text_query_cache_query: SharedString,
|
||||
pub(in crate::view) diff_selection_anchor: Option<usize>,
|
||||
pub(in crate::view) diff_selection_range: Option<(usize, usize)>,
|
||||
pub(in crate::view) diff_text_selecting: bool,
|
||||
|
|
@ -1513,6 +1720,9 @@ pub(in crate::view) struct MainPaneView {
|
|||
pub(in crate::view) file_diff_split_word_highlights_new: Vec<Option<Vec<Range<usize>>>>,
|
||||
pub(in crate::view) file_diff_cache_seq: u64,
|
||||
pub(in crate::view) file_diff_cache_inflight: Option<u64>,
|
||||
pub(in crate::view) file_diff_syntax_generation: u64,
|
||||
pub(in crate::view) prepared_syntax_documents:
|
||||
HashMap<PreparedSyntaxDocumentKey, rows::PreparedDiffSyntaxDocument>,
|
||||
|
||||
pub(in crate::view) file_image_diff_cache_repo_id: Option<RepoId>,
|
||||
pub(in crate::view) file_image_diff_cache_rev: u64,
|
||||
|
|
@ -1525,6 +1735,7 @@ pub(in crate::view) struct MainPaneView {
|
|||
|
||||
pub(in crate::view) worktree_preview_path: Option<std::path::PathBuf>,
|
||||
pub(in crate::view) worktree_preview: Loadable<Arc<Vec<String>>>,
|
||||
pub(in crate::view) worktree_preview_content_rev: u64,
|
||||
pub(in crate::view) worktree_preview_segments_cache_path: Option<std::path::PathBuf>,
|
||||
pub(in crate::view) worktree_preview_syntax_language: Option<rows::DiffSyntaxLanguage>,
|
||||
pub(in crate::view) worktree_preview_segments_cache: HashMap<usize, CachedDiffStyledText>,
|
||||
|
|
@ -1555,8 +1766,10 @@ pub(in crate::view) struct MainPaneView {
|
|||
HashMap<(usize, ThreeWayColumn), CachedDiffStyledText>,
|
||||
pub(in crate::view) conflict_resolved_preview_path: Option<std::path::PathBuf>,
|
||||
pub(in crate::view) conflict_resolved_preview_source_hash: Option<u64>,
|
||||
pub(in crate::view) conflict_resolved_preview_text: SharedString,
|
||||
pub(in crate::view) conflict_resolved_preview_syntax_language: Option<rows::DiffSyntaxLanguage>,
|
||||
pub(in crate::view) conflict_resolved_preview_lines: Vec<String>,
|
||||
pub(in crate::view) conflict_resolved_preview_line_count: usize,
|
||||
pub(in crate::view) conflict_resolved_preview_line_starts: Vec<usize>,
|
||||
pub(in crate::view) conflict_resolved_preview_segments_cache:
|
||||
HashMap<usize, CachedDiffStyledText>,
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,75 @@ impl MainPaneView {
|
|||
)
|
||||
}
|
||||
|
||||
pub(super) fn is_file_preview_active(&self) -> bool {
|
||||
let preview_path = self
|
||||
.untracked_worktree_preview_path()
|
||||
.or_else(|| self.added_file_preview_abs_path())
|
||||
.or_else(|| self.deleted_file_preview_abs_path());
|
||||
pub(in crate::view) fn is_file_preview_active(&self) -> bool {
|
||||
let is_commit_file_target = self.active_repo().is_some_and(|repo| {
|
||||
matches!(
|
||||
repo.diff_state.diff_target.as_ref(),
|
||||
Some(DiffTarget::Commit { path: Some(_), .. })
|
||||
)
|
||||
});
|
||||
let has_untracked_preview = self.untracked_worktree_preview_path().is_some_and(|p| {
|
||||
!crate::view::should_bypass_text_file_preview_for_path(&p)
|
||||
&& crate::view::is_existing_regular_file(&p)
|
||||
});
|
||||
let has_added_preview = self.added_file_preview_abs_path().is_some_and(|p| {
|
||||
!crate::view::should_bypass_text_file_preview_for_path(&p)
|
||||
&& !crate::view::is_existing_directory(&p)
|
||||
&& (crate::view::is_existing_regular_file(&p) || is_commit_file_target)
|
||||
});
|
||||
let has_deleted_preview = self.deleted_file_preview_abs_path().is_some_and(|p| {
|
||||
!crate::view::should_bypass_text_file_preview_for_path(&p)
|
||||
&& !crate::view::is_existing_directory(&p)
|
||||
});
|
||||
has_untracked_preview || has_added_preview || has_deleted_preview
|
||||
}
|
||||
|
||||
let Some(path) = preview_path else {
|
||||
return false;
|
||||
pub(in crate::view) fn is_worktree_target_directory(&self) -> bool {
|
||||
self.active_repo().is_some_and(|repo| {
|
||||
let Some(DiffTarget::WorkingTree { path, .. }) = repo.diff_state.diff_target.as_ref()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let abs_path = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
repo.spec.workdir.join(path)
|
||||
};
|
||||
crate::view::is_existing_directory(&abs_path)
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::view) fn untracked_directory_notice(&self) -> Option<SharedString> {
|
||||
let repo = self.active_repo()?;
|
||||
let DiffTarget::WorkingTree { path, area } = repo.diff_state.diff_target.as_ref()? else {
|
||||
return None;
|
||||
};
|
||||
!crate::view::should_bypass_text_file_preview_for_path(&path)
|
||||
let abs_path = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
repo.spec.workdir.join(path)
|
||||
};
|
||||
if !crate::view::is_existing_directory(&abs_path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_untracked = *area == DiffArea::Unstaged
|
||||
&& matches!(&repo.status, Loadable::Ready(status) if status
|
||||
.unstaged
|
||||
.iter()
|
||||
.any(|e| e.kind == FileStatusKind::Untracked && &e.path == path));
|
||||
|
||||
if is_untracked {
|
||||
Some(
|
||||
"Folder is untracked. Select a file inside it, or stage the folder to inspect tracked changes."
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
Some(
|
||||
"Selected path is a directory. Select a file inside it to preview its contents."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn worktree_preview_line_count(&self) -> Option<usize> {
|
||||
|
|
@ -169,15 +228,13 @@ impl MainPaneView {
|
|||
if should_reset {
|
||||
self.worktree_preview_scroll
|
||||
.scroll_to_item_strict(0, gpui::ScrollStrategy::Top);
|
||||
self.worktree_preview_syntax_language = rows::diff_syntax_language_for_path(&path);
|
||||
self.worktree_preview_path = Some(path);
|
||||
self.worktree_preview = Loadable::Loading;
|
||||
self.diff_horizontal_min_width = px(0.0);
|
||||
self.worktree_preview_segments_cache_path = None;
|
||||
self.worktree_preview_segments_cache.clear();
|
||||
} else if matches!(
|
||||
self.worktree_preview,
|
||||
Loadable::NotLoaded | Loadable::Error(_)
|
||||
) {
|
||||
} else if matches!(self.worktree_preview, Loadable::NotLoaded) {
|
||||
self.worktree_preview = Loadable::Loading;
|
||||
self.diff_horizontal_min_width = px(0.0);
|
||||
}
|
||||
|
|
@ -191,14 +248,12 @@ impl MainPaneView {
|
|||
let should_reload = match self.worktree_preview_path.as_ref() {
|
||||
Some(p) => p != &path,
|
||||
None => true,
|
||||
} || matches!(
|
||||
self.worktree_preview,
|
||||
Loadable::Error(_) | Loadable::NotLoaded
|
||||
);
|
||||
} || matches!(self.worktree_preview, Loadable::NotLoaded);
|
||||
if !should_reload {
|
||||
return;
|
||||
}
|
||||
|
||||
self.worktree_preview_syntax_language = rows::diff_syntax_language_for_path(&path);
|
||||
self.worktree_preview_path = Some(path.clone());
|
||||
self.worktree_preview = Loadable::Loading;
|
||||
self.diff_horizontal_min_width = px(0.0);
|
||||
|
|
@ -239,17 +294,24 @@ impl MainPaneView {
|
|||
}
|
||||
this.worktree_preview_scroll
|
||||
.scroll_to_item_strict(0, gpui::ScrollStrategy::Top);
|
||||
this.worktree_preview = match result {
|
||||
Ok(lines) => Loadable::Ready(lines),
|
||||
Err(e) => Loadable::Error(e),
|
||||
};
|
||||
match result {
|
||||
Ok(lines) => this.set_worktree_preview_ready_lines(path.clone(), lines, cx),
|
||||
Err(e) => {
|
||||
this.worktree_preview = Loadable::Error(e);
|
||||
this.worktree_preview_segments_cache_path = None;
|
||||
this.worktree_preview_segments_cache.clear();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(in super::super::super) fn try_populate_worktree_preview_from_diff_file(&mut self) {
|
||||
pub(in super::super::super) fn try_populate_worktree_preview_from_diff_file(
|
||||
&mut self,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) {
|
||||
let Some((abs_path, preview_result)) = (|| {
|
||||
let repo = self.active_repo()?;
|
||||
let path_from_target = match repo.diff_state.diff_target.as_ref()? {
|
||||
|
|
@ -362,11 +424,8 @@ impl MainPaneView {
|
|||
Ok(lines) => {
|
||||
self.worktree_preview_scroll
|
||||
.scroll_to_item_strict(0, gpui::ScrollStrategy::Top);
|
||||
self.worktree_preview_path = Some(abs_path);
|
||||
self.worktree_preview = Loadable::Ready(lines);
|
||||
self.set_worktree_preview_ready_lines(abs_path, lines, cx);
|
||||
self.diff_horizontal_min_width = px(0.0);
|
||||
self.worktree_preview_segments_cache_path = None;
|
||||
self.worktree_preview_segments_cache.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
if self.worktree_preview_path.as_ref() != Some(&abs_path)
|
||||
|
|
@ -381,6 +440,7 @@ impl MainPaneView {
|
|||
self.worktree_preview = Loadable::Error(e);
|
||||
self.diff_horizontal_min_width = px(0.0);
|
||||
self.worktree_preview_segments_cache_path = None;
|
||||
self.worktree_preview_syntax_language = None;
|
||||
self.worktree_preview_segments_cache.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
use super::{
|
||||
ClearDiffSelectionAction, ResolvedOutputConflictMarker, apply_conflict_choice_provenance_hints,
|
||||
apply_three_way_empty_base_provenance_hints, build_resolved_output_conflict_markers,
|
||||
clear_diff_selection_action, conflict_marker_nav_entries_from_markers,
|
||||
conflict_resolver_output_context_line, first_output_marker_line_for_conflict,
|
||||
apply_three_way_empty_base_provenance_hints, build_line_starts,
|
||||
build_resolved_output_conflict_markers, clear_diff_selection_action,
|
||||
conflict_marker_nav_entries_from_markers, conflict_resolver_output_context_line,
|
||||
dirty_byte_range_to_line_range, first_output_marker_line_for_conflict,
|
||||
focused_mergetool_save_exit_code, output_line_range_for_conflict_block_in_text,
|
||||
parse_conflict_canvas_rows_env, replace_output_lines_in_range, resolved_output_marker_for_line,
|
||||
parse_conflict_canvas_rows_env, remap_line_keyed_cache_for_delta,
|
||||
replace_output_lines_in_range, resolved_outline_delta_between_texts,
|
||||
resolved_output_conflict_block_ranges_in_text, resolved_output_marker_for_line,
|
||||
resolved_output_markers_for_text, split_target_conflict_block_into_subchunks,
|
||||
};
|
||||
use crate::view::GitCometViewMode;
|
||||
|
|
@ -12,6 +15,7 @@ use crate::view::conflict_resolver::{
|
|||
self, ConflictBlock, ConflictChoice, ConflictResolverViewMode, ConflictSegment,
|
||||
ResolvedLineSource, SourceLines,
|
||||
};
|
||||
use rustc_hash::FxHashMap as HashMap;
|
||||
|
||||
#[test]
|
||||
fn clear_diff_selection_action_is_clear_for_normal_mode() {
|
||||
|
|
@ -73,6 +77,71 @@ fn replace_output_lines_in_range_preserves_trailing_newline() {
|
|||
assert_eq!(next, "a\nx\ny\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_outline_delta_between_texts_clamps_to_utf8_boundaries() {
|
||||
let old_text = "prefix ä\nsuffix";
|
||||
let new_text = "prefix ö\nsuffix";
|
||||
let delta = resolved_outline_delta_between_texts(old_text, new_text).expect("delta");
|
||||
assert_eq!(old_text.get(delta.old_range.clone()), Some("ä"));
|
||||
assert_eq!(new_text.get(delta.new_range.clone()), Some("ö"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_byte_range_to_line_range_includes_line_join_delete() {
|
||||
let text = "a\nb\nc";
|
||||
let line_starts = build_line_starts(text);
|
||||
// Delete the newline between "a" and "b".
|
||||
let dirty = dirty_byte_range_to_line_range(&line_starts, text.len(), 1..2);
|
||||
assert_eq!(dirty, 0..2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_line_keyed_cache_for_delta_shifts_suffix_entries() {
|
||||
let mut cache: HashMap<usize, usize> = HashMap::default();
|
||||
cache.insert(0, 10);
|
||||
cache.insert(4, 40);
|
||||
cache.insert(7, 70);
|
||||
|
||||
remap_line_keyed_cache_for_delta(&mut cache, 2..5, 2..3);
|
||||
assert_eq!(cache.get(&0), Some(&10));
|
||||
assert_eq!(cache.get(&4), None);
|
||||
assert_eq!(cache.get(&5), Some(&70));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_output_conflict_block_ranges_match_point_lookup() {
|
||||
let segments = vec![
|
||||
ConflictSegment::Text("top\n".to_string()),
|
||||
ConflictSegment::Block(ConflictBlock {
|
||||
base: None,
|
||||
ours: "a\n".to_string(),
|
||||
theirs: "x\n".to_string(),
|
||||
choice: ConflictChoice::Ours,
|
||||
resolved: true,
|
||||
}),
|
||||
ConflictSegment::Text("mid\n".to_string()),
|
||||
ConflictSegment::Block(ConflictBlock {
|
||||
base: None,
|
||||
ours: "b\nc\n".to_string(),
|
||||
theirs: "y\n".to_string(),
|
||||
choice: ConflictChoice::Ours,
|
||||
resolved: true,
|
||||
}),
|
||||
];
|
||||
let output = conflict_resolver::generate_resolved_text(&segments);
|
||||
let ranges =
|
||||
resolved_output_conflict_block_ranges_in_text(&segments, &output).expect("block ranges");
|
||||
assert_eq!(ranges.len(), 2);
|
||||
assert_eq!(
|
||||
output_line_range_for_conflict_block_in_text(&segments, &output, 0),
|
||||
ranges.first().cloned()
|
||||
);
|
||||
assert_eq!(
|
||||
output_line_range_for_conflict_block_in_text(&segments, &output, 1),
|
||||
ranges.get(1).cloned()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_line_range_for_conflict_block_in_text_maps_middle_blocks_exactly() {
|
||||
let segments = vec![
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
mod details;
|
||||
mod history;
|
||||
mod main;
|
||||
pub(in crate::view) mod main;
|
||||
mod sidebar;
|
||||
|
||||
pub(super) use details::DetailsPaneView;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,26 @@ fn conflict_syntax_mode_for_total_rows(total_rows: usize) -> DiffSyntaxMode {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolved_output_line_text<'a>(text: &'a str, line_starts: &[usize], line_ix: usize) -> &'a str {
|
||||
if text.is_empty() {
|
||||
return "";
|
||||
}
|
||||
let text_len = text.len();
|
||||
let start = line_starts.get(line_ix).copied().unwrap_or(text_len);
|
||||
if start >= text_len {
|
||||
return "";
|
||||
}
|
||||
let mut end = line_starts
|
||||
.get(line_ix.saturating_add(1))
|
||||
.copied()
|
||||
.unwrap_or(text_len)
|
||||
.min(text_len);
|
||||
if end > start && text.as_bytes().get(end.saturating_sub(1)) == Some(&b'\n') {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
text.get(start..end).unwrap_or("")
|
||||
}
|
||||
|
||||
fn build_conflict_cached_diff_styled_text(
|
||||
theme: AppTheme,
|
||||
text: &str,
|
||||
|
|
@ -104,24 +124,15 @@ impl MainPaneView {
|
|||
let text = match col {
|
||||
ThreeWayColumn::Base => this
|
||||
.conflict_resolver
|
||||
.three_way_lines
|
||||
.base
|
||||
.get(ix)
|
||||
.map(|s| s.as_ref())
|
||||
.three_way_line_text(ThreeWayColumn::Base, ix)
|
||||
.unwrap_or(""),
|
||||
ThreeWayColumn::Ours => this
|
||||
.conflict_resolver
|
||||
.three_way_lines
|
||||
.ours
|
||||
.get(ix)
|
||||
.map(|s| s.as_ref())
|
||||
.three_way_line_text(ThreeWayColumn::Ours, ix)
|
||||
.unwrap_or(""),
|
||||
ThreeWayColumn::Theirs => this
|
||||
.conflict_resolver
|
||||
.three_way_lines
|
||||
.theirs
|
||||
.get(ix)
|
||||
.map(|s| s.as_ref())
|
||||
.three_way_line_text(ThreeWayColumn::Theirs, ix)
|
||||
.unwrap_or(""),
|
||||
};
|
||||
if text.is_empty() {
|
||||
|
|
@ -240,9 +251,15 @@ impl MainPaneView {
|
|||
elements.push(collapsed.into_any_element());
|
||||
}
|
||||
conflict_resolver::ThreeWayVisibleItem::Line(ix) => {
|
||||
let base_line = this.conflict_resolver.three_way_lines.base.get(ix);
|
||||
let ours_line = this.conflict_resolver.three_way_lines.ours.get(ix);
|
||||
let theirs_line = this.conflict_resolver.three_way_lines.theirs.get(ix);
|
||||
let base_line = this
|
||||
.conflict_resolver
|
||||
.three_way_line_text(ThreeWayColumn::Base, ix);
|
||||
let ours_line = this
|
||||
.conflict_resolver
|
||||
.three_way_line_text(ThreeWayColumn::Ours, ix);
|
||||
let theirs_line = this
|
||||
.conflict_resolver
|
||||
.three_way_line_text(ThreeWayColumn::Theirs, ix);
|
||||
let base_range_ix = this
|
||||
.conflict_resolver
|
||||
.three_way_line_conflict_map
|
||||
|
|
@ -415,13 +432,17 @@ impl MainPaneView {
|
|||
line_no: base_line_no,
|
||||
bg: if base_is_chosen { chosen_bg } else { base_bg },
|
||||
fg: base_fg,
|
||||
text: base_line.cloned().unwrap_or_default(),
|
||||
text: base_line
|
||||
.map(|line| SharedString::from(line.to_string()))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
ThreeWayCanvasColumn {
|
||||
line_no: ours_line_no,
|
||||
bg: if ours_is_chosen { chosen_bg } else { ours_bg },
|
||||
fg: ours_fg,
|
||||
text: ours_line.cloned().unwrap_or_default(),
|
||||
text: ours_line
|
||||
.map(|line| SharedString::from(line.to_string()))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
ThreeWayCanvasColumn {
|
||||
line_no: theirs_line_no,
|
||||
|
|
@ -431,7 +452,9 @@ impl MainPaneView {
|
|||
theirs_bg
|
||||
},
|
||||
fg: theirs_fg,
|
||||
text: theirs_line.cloned().unwrap_or_default(),
|
||||
text: theirs_line
|
||||
.map(|line| SharedString::from(line.to_string()))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
base_styled,
|
||||
ours_styled,
|
||||
|
|
@ -467,7 +490,9 @@ impl MainPaneView {
|
|||
.child(base_line_no),
|
||||
)
|
||||
.child(conflict_diff_text_cell(
|
||||
base_line.cloned().unwrap_or_default(),
|
||||
base_line
|
||||
.map(|line| SharedString::from(line.to_string()))
|
||||
.unwrap_or_default(),
|
||||
base_styled,
|
||||
show_ws,
|
||||
));
|
||||
|
|
@ -493,7 +518,9 @@ impl MainPaneView {
|
|||
.child(ours_line_no),
|
||||
)
|
||||
.child(conflict_diff_text_cell(
|
||||
ours_line.cloned().unwrap_or_default(),
|
||||
ours_line
|
||||
.map(|line| SharedString::from(line.to_string()))
|
||||
.unwrap_or_default(),
|
||||
ours_styled,
|
||||
show_ws,
|
||||
));
|
||||
|
|
@ -520,7 +547,9 @@ impl MainPaneView {
|
|||
.child(theirs_line_no),
|
||||
)
|
||||
.child(conflict_diff_text_cell(
|
||||
theirs_line.cloned().unwrap_or_default(),
|
||||
theirs_line
|
||||
.map(|line| SharedString::from(line.to_string()))
|
||||
.unwrap_or_default(),
|
||||
theirs_styled,
|
||||
show_ws,
|
||||
));
|
||||
|
|
@ -737,7 +766,7 @@ impl MainPaneView {
|
|||
|
||||
let elements: Vec<AnyElement> = range
|
||||
.map(|ix| {
|
||||
if this.conflict_resolved_preview_lines.get(ix).is_none() {
|
||||
if ix >= this.conflict_resolved_preview_line_count {
|
||||
return div()
|
||||
.id(("conflict_resolved_preview_oob", ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -899,6 +928,152 @@ impl MainPaneView {
|
|||
elements
|
||||
}
|
||||
|
||||
pub(in super::super) fn render_conflict_resolved_output_rows(
|
||||
this: &mut Self,
|
||||
range: Range<usize>,
|
||||
_window: &mut Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
let _perf_scope = perf::span(ConflictPerfSpan::RenderResolvedPreviewRows);
|
||||
let requested_rows = range.len();
|
||||
let theme = this.theme;
|
||||
let syntax_language = this.conflict_resolved_preview_syntax_language;
|
||||
let syntax_mode =
|
||||
conflict_syntax_mode_for_total_rows(this.conflict_resolved_preview_line_count);
|
||||
let line_starts = &this.conflict_resolved_preview_line_starts;
|
||||
let line_texts: Vec<SharedString> =
|
||||
this.conflict_resolver_input.read_with(cx, |input, _| {
|
||||
let text = input.text();
|
||||
range
|
||||
.clone()
|
||||
.map(|ix| {
|
||||
resolved_output_line_text(text, line_starts, ix)
|
||||
.to_string()
|
||||
.into()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let unresolved_row_bg =
|
||||
with_alpha(theme.colors.danger, if theme.is_dark { 0.18 } else { 0.10 });
|
||||
let resolved_row_bg = with_alpha(
|
||||
theme.colors.success,
|
||||
if theme.is_dark { 0.12 } else { 0.08 },
|
||||
);
|
||||
|
||||
let elements: Vec<AnyElement> = range
|
||||
.zip(line_texts)
|
||||
.map(|(ix, line_text)| {
|
||||
if ix >= this.conflict_resolved_preview_line_count {
|
||||
return div()
|
||||
.id(("conflict_resolved_output_oob", ix))
|
||||
.h(px(20.0))
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(theme.colors.text_muted)
|
||||
.child("")
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let row_content = if syntax_language.is_some() && !line_text.is_empty() {
|
||||
let styled = this
|
||||
.conflict_resolved_preview_segments_cache
|
||||
.entry(ix)
|
||||
.or_insert_with(|| {
|
||||
build_cached_diff_styled_text(
|
||||
theme,
|
||||
line_text.as_ref(),
|
||||
&[],
|
||||
"",
|
||||
syntax_language,
|
||||
syntax_mode,
|
||||
None,
|
||||
)
|
||||
});
|
||||
if styled.text.as_ref() != line_text.as_ref() {
|
||||
*styled = build_cached_diff_styled_text(
|
||||
theme,
|
||||
line_text.as_ref(),
|
||||
&[],
|
||||
"",
|
||||
syntax_language,
|
||||
syntax_mode,
|
||||
None,
|
||||
);
|
||||
}
|
||||
if styled.highlights.is_empty() {
|
||||
div()
|
||||
.w_full()
|
||||
.min_w(px(0.0))
|
||||
.overflow_hidden()
|
||||
.child(styled.text.clone())
|
||||
.into_any_element()
|
||||
} else {
|
||||
div()
|
||||
.w_full()
|
||||
.min_w(px(0.0))
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
gpui::StyledText::new(styled.text.clone())
|
||||
.with_highlights(styled.highlights.iter().cloned()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
} else {
|
||||
div()
|
||||
.w_full()
|
||||
.min_w(px(0.0))
|
||||
.overflow_hidden()
|
||||
.child(line_text)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
let conflict_marker = this
|
||||
.conflict_resolver
|
||||
.resolved_output_conflict_markers
|
||||
.get(ix)
|
||||
.copied()
|
||||
.flatten();
|
||||
let row_bg = conflict_marker.map(|marker| {
|
||||
if marker.unresolved {
|
||||
unresolved_row_bg
|
||||
} else {
|
||||
resolved_row_bg
|
||||
}
|
||||
});
|
||||
|
||||
div()
|
||||
.id(("conflict_resolved_output_row", ix))
|
||||
.h(px(20.0))
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.font_family("monospace")
|
||||
.text_color(theme.colors.text)
|
||||
.whitespace_nowrap()
|
||||
.when_some(row_bg, |d, bg| d.bg(bg))
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |this, e: &MouseDownEvent, window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.open_conflict_resolver_output_context_menu_for_line(
|
||||
ix, e.position, window, cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(row_content)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
perf::record_row_batch(
|
||||
ConflictPerfRenderLane::ResolvedPreview,
|
||||
requested_rows,
|
||||
elements.len(),
|
||||
);
|
||||
elements
|
||||
}
|
||||
|
||||
pub(in super::super) fn render_conflict_compare_diff_rows(
|
||||
this: &mut Self,
|
||||
range: Range<usize>,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,39 @@ use super::diff_text::*;
|
|||
use super::*;
|
||||
|
||||
impl MainPaneView {
|
||||
fn diff_text_segments_cache_get_for_query(
|
||||
&mut self,
|
||||
key: usize,
|
||||
query: &str,
|
||||
) -> Option<CachedDiffStyledText> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return self.diff_text_segments_cache_get(key).cloned();
|
||||
}
|
||||
|
||||
self.sync_diff_text_query_overlay_cache(query);
|
||||
if self.diff_text_query_segments_cache.len() <= key {
|
||||
self.diff_text_query_segments_cache
|
||||
.resize_with(key + 1, || None);
|
||||
}
|
||||
|
||||
if self
|
||||
.diff_text_query_segments_cache
|
||||
.get(key)
|
||||
.and_then(Option::as_ref)
|
||||
.is_none()
|
||||
{
|
||||
let base = self.diff_text_segments_cache_get(key)?.clone();
|
||||
let overlaid = build_cached_diff_query_overlay_styled_text(self.theme, &base, query);
|
||||
self.diff_text_query_segments_cache[key] = Some(overlaid);
|
||||
}
|
||||
|
||||
self.diff_text_query_segments_cache
|
||||
.get(key)
|
||||
.and_then(Option::as_ref)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(in super::super) fn render_diff_rows(
|
||||
this: &mut Self,
|
||||
range: Range<usize>,
|
||||
|
|
@ -20,31 +53,19 @@ impl MainPaneView {
|
|||
if this.is_file_diff_view_active() {
|
||||
let theme = this.theme;
|
||||
let empty_ranges: &[Range<usize>] = &[];
|
||||
let syntax_mode =
|
||||
let configured_syntax_mode =
|
||||
if this.file_diff_inline_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
DiffSyntaxMode::Auto
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
let language = this.file_diff_cache_language;
|
||||
let syntax_document = language.and_then(|language| {
|
||||
prepare_diff_syntax_document(
|
||||
language,
|
||||
syntax_mode,
|
||||
this.file_diff_inline_cache.iter().map(|line| {
|
||||
if matches!(
|
||||
line.kind,
|
||||
gitcomet_core::domain::DiffLineKind::Add
|
||||
| gitcomet_core::domain::DiffLineKind::Remove
|
||||
| gitcomet_core::domain::DiffLineKind::Context
|
||||
) {
|
||||
diff_content_text(line)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}),
|
||||
)
|
||||
});
|
||||
let syntax_document = this.file_diff_inline_prepared_syntax_document();
|
||||
let syntax_mode = if syntax_document.is_some() {
|
||||
configured_syntax_mode
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
|
||||
return range
|
||||
.map(|visible_ix| {
|
||||
|
|
@ -52,7 +73,7 @@ impl MainPaneView {
|
|||
.diff_selection_range
|
||||
.is_some_and(|(a, b)| visible_ix >= a.min(b) && visible_ix <= a.max(b));
|
||||
|
||||
let Some(inline_ix) = this.diff_visible_indices.get(visible_ix).copied() else {
|
||||
let Some(inline_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return div()
|
||||
.id(("diff_missing", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -102,7 +123,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
diff_content_text(line),
|
||||
word_ranges,
|
||||
&query,
|
||||
"",
|
||||
DiffSyntaxConfig {
|
||||
language,
|
||||
mode: syntax_mode,
|
||||
|
|
@ -116,6 +137,12 @@ impl MainPaneView {
|
|||
this.diff_text_segments_cache_set(inline_ix, computed);
|
||||
}
|
||||
|
||||
let styled =
|
||||
this.diff_text_segments_cache_get_for_query(inline_ix, query.as_ref());
|
||||
debug_assert!(
|
||||
styled.is_some(),
|
||||
"diff text segment cache missing for inline row {inline_ix} after populate"
|
||||
);
|
||||
let Some(line) = this.file_diff_inline_cache.get(inline_ix) else {
|
||||
return div()
|
||||
.id(("diff_oob", visible_ix))
|
||||
|
|
@ -126,11 +153,6 @@ impl MainPaneView {
|
|||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
let styled = this.diff_text_segments_cache_get(inline_ix);
|
||||
debug_assert!(
|
||||
styled.is_some(),
|
||||
"diff text segment cache missing for inline row {inline_ix} after populate"
|
||||
);
|
||||
|
||||
diff_row(
|
||||
theme,
|
||||
|
|
@ -142,7 +164,7 @@ impl MainPaneView {
|
|||
line,
|
||||
None,
|
||||
None,
|
||||
styled,
|
||||
styled.as_ref(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -153,7 +175,7 @@ impl MainPaneView {
|
|||
let theme = this.theme;
|
||||
let repo_id_for_context_menu = this.active_repo_id();
|
||||
let active_context_menu_invoker = this.active_context_menu_invoker.clone();
|
||||
let syntax_mode = if this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
let syntax_mode = if this.patch_diff_row_len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
DiffSyntaxMode::Auto
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
|
|
@ -164,7 +186,7 @@ impl MainPaneView {
|
|||
.diff_selection_range
|
||||
.is_some_and(|(a, b)| visible_ix >= a.min(b) && visible_ix <= a.max(b));
|
||||
|
||||
let Some(src_ix) = this.diff_visible_indices.get(visible_ix).copied() else {
|
||||
let Some(src_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return div()
|
||||
.id(("diff_missing", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -180,6 +202,7 @@ impl MainPaneView {
|
|||
.copied()
|
||||
.unwrap_or(DiffClickKind::Line);
|
||||
|
||||
this.ensure_patch_diff_word_highlight_for_src_ix(src_ix);
|
||||
let word_ranges: &[Range<usize>] = this
|
||||
.diff_word_highlights
|
||||
.get(src_ix)
|
||||
|
|
@ -192,7 +215,7 @@ impl MainPaneView {
|
|||
|
||||
let should_style = matches!(click_kind, DiffClickKind::Line) || !query.is_empty();
|
||||
if should_style && this.diff_text_segments_cache_get(src_ix).is_none() {
|
||||
let Some(line) = this.diff_cache.get(src_ix) else {
|
||||
let Some(line) = this.patch_diff_row(src_ix) else {
|
||||
return div()
|
||||
.id(("diff_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -214,9 +237,9 @@ impl MainPaneView {
|
|||
|
||||
build_cached_diff_styled_text(
|
||||
theme,
|
||||
diff_content_text(line),
|
||||
diff_content_text(&line),
|
||||
word_ranges,
|
||||
&query,
|
||||
"",
|
||||
language,
|
||||
syntax_mode,
|
||||
word_color,
|
||||
|
|
@ -228,7 +251,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
display.as_ref(),
|
||||
&[] as &[Range<usize>],
|
||||
&query,
|
||||
"",
|
||||
None,
|
||||
syntax_mode,
|
||||
None,
|
||||
|
|
@ -237,11 +260,11 @@ impl MainPaneView {
|
|||
this.diff_text_segments_cache_set(src_ix, computed);
|
||||
}
|
||||
|
||||
let styled: Option<&CachedDiffStyledText> = should_style
|
||||
.then(|| this.diff_text_segments_cache_get(src_ix))
|
||||
let styled = should_style
|
||||
.then(|| this.diff_text_segments_cache_get_for_query(src_ix, query.as_ref()))
|
||||
.flatten();
|
||||
|
||||
let Some(line) = this.diff_cache.get(src_ix) else {
|
||||
let Some(line) = this.patch_diff_row(src_ix) else {
|
||||
return div()
|
||||
.id(("diff_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -271,10 +294,10 @@ impl MainPaneView {
|
|||
selected,
|
||||
DiffViewMode::Inline,
|
||||
min_width,
|
||||
line,
|
||||
&line,
|
||||
file_stat,
|
||||
header_display,
|
||||
styled,
|
||||
styled.as_ref(),
|
||||
context_menu_active,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -299,22 +322,19 @@ impl MainPaneView {
|
|||
if this.is_file_diff_view_active() {
|
||||
let theme = this.theme;
|
||||
let empty_ranges: &[Range<usize>] = &[];
|
||||
let syntax_mode =
|
||||
let configured_syntax_mode =
|
||||
if this.file_diff_cache_rows.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
DiffSyntaxMode::Auto
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
let language = this.file_diff_cache_language;
|
||||
let syntax_document = language.and_then(|language| {
|
||||
prepare_diff_syntax_document(
|
||||
language,
|
||||
syntax_mode,
|
||||
this.file_diff_cache_rows
|
||||
.iter()
|
||||
.map(|row| row.old.as_deref().unwrap_or("")),
|
||||
)
|
||||
});
|
||||
let syntax_document = this.file_diff_split_left_prepared_syntax_document();
|
||||
let syntax_mode = if syntax_document.is_some() {
|
||||
configured_syntax_mode
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
|
||||
return range
|
||||
.map(|visible_ix| {
|
||||
|
|
@ -322,7 +342,7 @@ impl MainPaneView {
|
|||
.diff_selection_range
|
||||
.is_some_and(|(a, b)| visible_ix >= a.min(b) && visible_ix <= a.max(b));
|
||||
|
||||
let Some(row_ix) = this.diff_visible_indices.get(visible_ix).copied() else {
|
||||
let Some(row_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_left_missing", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -365,7 +385,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
text,
|
||||
word_ranges,
|
||||
&query,
|
||||
"",
|
||||
DiffSyntaxConfig {
|
||||
language,
|
||||
mode: syntax_mode,
|
||||
|
|
@ -380,6 +400,22 @@ impl MainPaneView {
|
|||
}
|
||||
}
|
||||
|
||||
let row_has_old = this
|
||||
.file_diff_cache_rows
|
||||
.get(row_ix)
|
||||
.is_some_and(|row| row.old.is_some());
|
||||
let styled = if row_has_old {
|
||||
key.and_then(|k| {
|
||||
this.diff_text_segments_cache_get_for_query(k, query.as_ref())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
debug_assert!(
|
||||
!row_has_old || key.is_none() || styled.is_some(),
|
||||
"diff text segment cache missing for split-left row {row_ix} after populate"
|
||||
);
|
||||
|
||||
let Some(row) = this.file_diff_cache_rows.get(row_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_left_oob", visible_ix))
|
||||
|
|
@ -390,12 +426,6 @@ impl MainPaneView {
|
|||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
let styled: Option<&CachedDiffStyledText> = if row.old.is_some() {
|
||||
key.and_then(|k| this.diff_text_segments_cache_get(k))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
patch_split_column_row(
|
||||
theme,
|
||||
PatchSplitColumn::Left,
|
||||
|
|
@ -403,7 +433,7 @@ impl MainPaneView {
|
|||
selected,
|
||||
min_width,
|
||||
row,
|
||||
styled,
|
||||
styled.as_ref(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
|
@ -411,7 +441,7 @@ impl MainPaneView {
|
|||
}
|
||||
|
||||
let theme = this.theme;
|
||||
let syntax_mode = if this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
let syntax_mode = if this.patch_diff_row_len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
DiffSyntaxMode::Auto
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
|
|
@ -423,7 +453,7 @@ impl MainPaneView {
|
|||
.diff_selection_range
|
||||
.is_some_and(|(a, b)| visible_ix >= a.min(b) && visible_ix <= a.max(b));
|
||||
|
||||
let Some(row_ix) = this.diff_visible_indices.get(visible_ix).copied() else {
|
||||
let Some(row_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_left_missing", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -433,7 +463,7 @@ impl MainPaneView {
|
|||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
let Some(row) = this.diff_split_cache.get(row_ix) else {
|
||||
let Some(row) = this.patch_diff_split_row(row_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_left_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -445,34 +475,23 @@ impl MainPaneView {
|
|||
};
|
||||
|
||||
match row {
|
||||
PatchSplitRow::Aligned { old_src_ix, .. } => {
|
||||
if let Some(src_ix) = *old_src_ix
|
||||
PatchSplitRow::Aligned {
|
||||
row, old_src_ix, ..
|
||||
} => {
|
||||
if let Some(src_ix) = old_src_ix
|
||||
&& this.diff_text_segments_cache_get(src_ix).is_none()
|
||||
{
|
||||
let Some(PatchSplitRow::Aligned { row, .. }) =
|
||||
this.diff_split_cache.get(row_ix)
|
||||
else {
|
||||
return div()
|
||||
.id(("diff_split_left_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(theme.colors.text_muted)
|
||||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let text = row.old.as_deref().unwrap_or("");
|
||||
let language =
|
||||
this.diff_language_for_src_ix.get(src_ix).copied().flatten();
|
||||
this.ensure_patch_diff_word_highlight_for_src_ix(src_ix);
|
||||
let word_ranges: &[Range<usize>] = this
|
||||
.diff_word_highlights
|
||||
.get(src_ix)
|
||||
.and_then(|r| r.as_ref().map(Vec::as_slice))
|
||||
.unwrap_or(empty_ranges);
|
||||
let word_color =
|
||||
this.diff_cache
|
||||
.get(src_ix)
|
||||
this.patch_diff_row(src_ix)
|
||||
.and_then(|line| match line.kind {
|
||||
gitcomet_core::domain::DiffLineKind::Add => {
|
||||
Some(theme.colors.success)
|
||||
|
|
@ -487,7 +506,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
text,
|
||||
word_ranges,
|
||||
&query,
|
||||
"",
|
||||
language,
|
||||
syntax_mode,
|
||||
word_color,
|
||||
|
|
@ -495,22 +514,9 @@ impl MainPaneView {
|
|||
this.diff_text_segments_cache_set(src_ix, computed);
|
||||
}
|
||||
|
||||
let Some(PatchSplitRow::Aligned {
|
||||
row, old_src_ix, ..
|
||||
}) = this.diff_split_cache.get(row_ix)
|
||||
else {
|
||||
return div()
|
||||
.id(("diff_split_left_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(theme.colors.text_muted)
|
||||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let styled =
|
||||
old_src_ix.and_then(|src_ix| this.diff_text_segments_cache_get(src_ix));
|
||||
let styled = old_src_ix.and_then(|src_ix| {
|
||||
this.diff_text_segments_cache_get_for_query(src_ix, query.as_ref())
|
||||
});
|
||||
|
||||
patch_split_column_row(
|
||||
theme,
|
||||
|
|
@ -518,15 +524,13 @@ impl MainPaneView {
|
|||
visible_ix,
|
||||
selected,
|
||||
min_width,
|
||||
row,
|
||||
styled,
|
||||
&row,
|
||||
styled.as_ref(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
PatchSplitRow::Raw { src_ix, click_kind } => {
|
||||
let src_ix = *src_ix;
|
||||
let click_kind = *click_kind;
|
||||
if this.diff_cache.get(src_ix).is_none() {
|
||||
if this.patch_diff_row(src_ix).is_none() {
|
||||
return div()
|
||||
.id(("diff_split_left_src_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -545,7 +549,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
display.as_ref(),
|
||||
&[],
|
||||
&query,
|
||||
"",
|
||||
None,
|
||||
syntax_mode,
|
||||
None,
|
||||
|
|
@ -553,9 +557,11 @@ impl MainPaneView {
|
|||
this.diff_text_segments_cache_set(src_ix, computed);
|
||||
}
|
||||
let styled = should_style
|
||||
.then(|| this.diff_text_segments_cache_get(src_ix))
|
||||
.then(|| {
|
||||
this.diff_text_segments_cache_get_for_query(src_ix, query.as_ref())
|
||||
})
|
||||
.flatten();
|
||||
let Some(line) = this.diff_cache.get(src_ix) else {
|
||||
let Some(line) = this.patch_diff_row(src_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_left_src_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -565,6 +571,12 @@ impl MainPaneView {
|
|||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
if should_hide_unified_diff_header_line(&line) {
|
||||
return div()
|
||||
.id(("diff_split_left_hidden_header", visible_ix))
|
||||
.h(px(0.0))
|
||||
.into_any_element();
|
||||
}
|
||||
let context_menu_active = click_kind == DiffClickKind::HunkHeader
|
||||
&& this.active_repo_id().is_some_and(|repo_id| {
|
||||
let invoker: SharedString =
|
||||
|
|
@ -578,10 +590,10 @@ impl MainPaneView {
|
|||
click_kind,
|
||||
selected,
|
||||
min_width,
|
||||
line,
|
||||
&line,
|
||||
file_stat,
|
||||
this.diff_header_display_cache.get(&src_ix).cloned(),
|
||||
styled,
|
||||
styled.as_ref(),
|
||||
context_menu_active,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -608,22 +620,19 @@ impl MainPaneView {
|
|||
if this.is_file_diff_view_active() {
|
||||
let theme = this.theme;
|
||||
let empty_ranges: &[Range<usize>] = &[];
|
||||
let syntax_mode =
|
||||
let configured_syntax_mode =
|
||||
if this.file_diff_cache_rows.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
DiffSyntaxMode::Auto
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
let language = this.file_diff_cache_language;
|
||||
let syntax_document = language.and_then(|language| {
|
||||
prepare_diff_syntax_document(
|
||||
language,
|
||||
syntax_mode,
|
||||
this.file_diff_cache_rows
|
||||
.iter()
|
||||
.map(|row| row.new.as_deref().unwrap_or("")),
|
||||
)
|
||||
});
|
||||
let syntax_document = this.file_diff_split_right_prepared_syntax_document();
|
||||
let syntax_mode = if syntax_document.is_some() {
|
||||
configured_syntax_mode
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
|
||||
return range
|
||||
.map(|visible_ix| {
|
||||
|
|
@ -631,7 +640,7 @@ impl MainPaneView {
|
|||
.diff_selection_range
|
||||
.is_some_and(|(a, b)| visible_ix >= a.min(b) && visible_ix <= a.max(b));
|
||||
|
||||
let Some(row_ix) = this.diff_visible_indices.get(visible_ix).copied() else {
|
||||
let Some(row_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_right_missing", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -674,7 +683,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
text,
|
||||
word_ranges,
|
||||
&query,
|
||||
"",
|
||||
DiffSyntaxConfig {
|
||||
language,
|
||||
mode: syntax_mode,
|
||||
|
|
@ -689,6 +698,22 @@ impl MainPaneView {
|
|||
}
|
||||
}
|
||||
|
||||
let row_has_new = this
|
||||
.file_diff_cache_rows
|
||||
.get(row_ix)
|
||||
.is_some_and(|row| row.new.is_some());
|
||||
let styled = if row_has_new {
|
||||
key.and_then(|k| {
|
||||
this.diff_text_segments_cache_get_for_query(k, query.as_ref())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
debug_assert!(
|
||||
!row_has_new || key.is_none() || styled.is_some(),
|
||||
"diff text segment cache missing for split-right row {row_ix} after populate"
|
||||
);
|
||||
|
||||
let Some(row) = this.file_diff_cache_rows.get(row_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_right_oob", visible_ix))
|
||||
|
|
@ -699,12 +724,6 @@ impl MainPaneView {
|
|||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
let styled: Option<&CachedDiffStyledText> = if row.new.is_some() {
|
||||
key.and_then(|k| this.diff_text_segments_cache_get(k))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
patch_split_column_row(
|
||||
theme,
|
||||
PatchSplitColumn::Right,
|
||||
|
|
@ -712,7 +731,7 @@ impl MainPaneView {
|
|||
selected,
|
||||
min_width,
|
||||
row,
|
||||
styled,
|
||||
styled.as_ref(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
|
@ -720,7 +739,7 @@ impl MainPaneView {
|
|||
}
|
||||
|
||||
let theme = this.theme;
|
||||
let syntax_mode = if this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
let syntax_mode = if this.patch_diff_row_len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
DiffSyntaxMode::Auto
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
|
|
@ -732,7 +751,7 @@ impl MainPaneView {
|
|||
.diff_selection_range
|
||||
.is_some_and(|(a, b)| visible_ix >= a.min(b) && visible_ix <= a.max(b));
|
||||
|
||||
let Some(row_ix) = this.diff_visible_indices.get(visible_ix).copied() else {
|
||||
let Some(row_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_right_missing", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -742,7 +761,7 @@ impl MainPaneView {
|
|||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
let Some(row) = this.diff_split_cache.get(row_ix) else {
|
||||
let Some(row) = this.patch_diff_split_row(row_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_right_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -754,34 +773,23 @@ impl MainPaneView {
|
|||
};
|
||||
|
||||
match row {
|
||||
PatchSplitRow::Aligned { new_src_ix, .. } => {
|
||||
if let Some(src_ix) = *new_src_ix
|
||||
PatchSplitRow::Aligned {
|
||||
row, new_src_ix, ..
|
||||
} => {
|
||||
if let Some(src_ix) = new_src_ix
|
||||
&& this.diff_text_segments_cache_get(src_ix).is_none()
|
||||
{
|
||||
let Some(PatchSplitRow::Aligned { row, .. }) =
|
||||
this.diff_split_cache.get(row_ix)
|
||||
else {
|
||||
return div()
|
||||
.id(("diff_split_right_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(theme.colors.text_muted)
|
||||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let text = row.new.as_deref().unwrap_or("");
|
||||
let language =
|
||||
this.diff_language_for_src_ix.get(src_ix).copied().flatten();
|
||||
this.ensure_patch_diff_word_highlight_for_src_ix(src_ix);
|
||||
let word_ranges: &[Range<usize>] = this
|
||||
.diff_word_highlights
|
||||
.get(src_ix)
|
||||
.and_then(|r| r.as_ref().map(Vec::as_slice))
|
||||
.unwrap_or(empty_ranges);
|
||||
let word_color =
|
||||
this.diff_cache
|
||||
.get(src_ix)
|
||||
this.patch_diff_row(src_ix)
|
||||
.and_then(|line| match line.kind {
|
||||
gitcomet_core::domain::DiffLineKind::Add => {
|
||||
Some(theme.colors.success)
|
||||
|
|
@ -796,7 +804,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
text,
|
||||
word_ranges,
|
||||
&query,
|
||||
"",
|
||||
language,
|
||||
syntax_mode,
|
||||
word_color,
|
||||
|
|
@ -804,22 +812,9 @@ impl MainPaneView {
|
|||
this.diff_text_segments_cache_set(src_ix, computed);
|
||||
}
|
||||
|
||||
let Some(PatchSplitRow::Aligned {
|
||||
row, new_src_ix, ..
|
||||
}) = this.diff_split_cache.get(row_ix)
|
||||
else {
|
||||
return div()
|
||||
.id(("diff_split_right_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(theme.colors.text_muted)
|
||||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let styled =
|
||||
new_src_ix.and_then(|src_ix| this.diff_text_segments_cache_get(src_ix));
|
||||
let styled = new_src_ix.and_then(|src_ix| {
|
||||
this.diff_text_segments_cache_get_for_query(src_ix, query.as_ref())
|
||||
});
|
||||
|
||||
patch_split_column_row(
|
||||
theme,
|
||||
|
|
@ -827,15 +822,13 @@ impl MainPaneView {
|
|||
visible_ix,
|
||||
selected,
|
||||
min_width,
|
||||
row,
|
||||
styled,
|
||||
&row,
|
||||
styled.as_ref(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
PatchSplitRow::Raw { src_ix, click_kind } => {
|
||||
let src_ix = *src_ix;
|
||||
let click_kind = *click_kind;
|
||||
if this.diff_cache.get(src_ix).is_none() {
|
||||
if this.patch_diff_row(src_ix).is_none() {
|
||||
return div()
|
||||
.id(("diff_split_right_src_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -854,7 +847,7 @@ impl MainPaneView {
|
|||
theme,
|
||||
display.as_ref(),
|
||||
&[],
|
||||
&query,
|
||||
"",
|
||||
None,
|
||||
syntax_mode,
|
||||
None,
|
||||
|
|
@ -862,9 +855,11 @@ impl MainPaneView {
|
|||
this.diff_text_segments_cache_set(src_ix, computed);
|
||||
}
|
||||
let styled = should_style
|
||||
.then(|| this.diff_text_segments_cache_get(src_ix))
|
||||
.then(|| {
|
||||
this.diff_text_segments_cache_get_for_query(src_ix, query.as_ref())
|
||||
})
|
||||
.flatten();
|
||||
let Some(line) = this.diff_cache.get(src_ix) else {
|
||||
let Some(line) = this.patch_diff_row(src_ix) else {
|
||||
return div()
|
||||
.id(("diff_split_right_src_oob", visible_ix))
|
||||
.h(px(20.0))
|
||||
|
|
@ -874,6 +869,12 @@ impl MainPaneView {
|
|||
.child("")
|
||||
.into_any_element();
|
||||
};
|
||||
if should_hide_unified_diff_header_line(&line) {
|
||||
return div()
|
||||
.id(("diff_split_right_hidden_header", visible_ix))
|
||||
.h(px(0.0))
|
||||
.into_any_element();
|
||||
}
|
||||
let context_menu_active = click_kind == DiffClickKind::HunkHeader
|
||||
&& this.active_repo_id().is_some_and(|repo_id| {
|
||||
let invoker: SharedString =
|
||||
|
|
@ -887,10 +888,10 @@ impl MainPaneView {
|
|||
click_kind,
|
||||
selected,
|
||||
min_width,
|
||||
line,
|
||||
&line,
|
||||
file_stat,
|
||||
this.diff_header_display_cache.get(&src_ix).cloned(),
|
||||
styled,
|
||||
styled.as_ref(),
|
||||
context_menu_active,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -1003,7 +1004,7 @@ fn diff_row(
|
|||
let Some(repo_id) = this.active_repo_id() else {
|
||||
return;
|
||||
};
|
||||
let Some(&src_ix) = this.diff_visible_indices.get(visible_ix) else {
|
||||
let Some(src_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return;
|
||||
};
|
||||
let context_menu_invoker: SharedString =
|
||||
|
|
@ -1277,17 +1278,16 @@ fn patch_split_header_row(
|
|||
let Some(repo_id) = this.active_repo_id() else {
|
||||
return;
|
||||
};
|
||||
let Some(&row_ix) = this.diff_visible_indices.get(visible_ix) else {
|
||||
let Some(row_ix) = this.diff_mapped_ix_for_visible_ix(visible_ix) else {
|
||||
return;
|
||||
};
|
||||
let Some(PatchSplitRow::Raw {
|
||||
src_ix,
|
||||
click_kind: DiffClickKind::HunkHeader,
|
||||
}) = this.diff_split_cache.get(row_ix)
|
||||
}) = this.patch_diff_split_row(row_ix)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let src_ix = *src_ix;
|
||||
let context_menu_invoker: SharedString =
|
||||
format!("diff_hunk_menu_{}_{}", repo_id.0, src_ix).into();
|
||||
this.activate_context_menu_invoker(context_menu_invoker, cx);
|
||||
|
|
|
|||
|
|
@ -5,14 +5,26 @@ use std::sync::{Arc, OnceLock};
|
|||
mod syntax;
|
||||
|
||||
pub(in crate::view) use syntax::{
|
||||
DiffSyntaxLanguage, DiffSyntaxMode, diff_syntax_language_for_path,
|
||||
DiffSyntaxBudget, DiffSyntaxLanguage, DiffSyntaxMode, diff_syntax_language_for_path,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) struct PreparedDiffSyntaxDocument {
|
||||
pub(in crate::view) struct PreparedDiffSyntaxDocument {
|
||||
inner: syntax::PreparedSyntaxDocument,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(in crate::view) struct BackgroundPreparedDiffSyntaxDocument {
|
||||
inner: syntax::PreparedSyntaxDocumentData,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(in crate::view) enum PrepareDiffSyntaxDocumentResult {
|
||||
Ready(PreparedDiffSyntaxDocument),
|
||||
TimedOut,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) struct DiffSyntaxConfig {
|
||||
pub language: Option<DiffSyntaxLanguage>,
|
||||
|
|
@ -37,6 +49,134 @@ where
|
|||
.map(|inner| PreparedDiffSyntaxDocument { inner })
|
||||
}
|
||||
|
||||
pub(in crate::view) fn prepare_diff_syntax_document_with_budget<'a, I>(
|
||||
language: DiffSyntaxLanguage,
|
||||
syntax_mode: DiffSyntaxMode,
|
||||
lines: I,
|
||||
budget: DiffSyntaxBudget,
|
||||
) -> PrepareDiffSyntaxDocumentResult
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
match syntax::prepare_treesitter_document_with_budget(language, syntax_mode, lines, budget) {
|
||||
syntax::PrepareTreesitterDocumentResult::Ready(inner) => {
|
||||
PrepareDiffSyntaxDocumentResult::Ready(PreparedDiffSyntaxDocument { inner })
|
||||
}
|
||||
syntax::PrepareTreesitterDocumentResult::TimedOut => {
|
||||
PrepareDiffSyntaxDocumentResult::TimedOut
|
||||
}
|
||||
syntax::PrepareTreesitterDocumentResult::Unsupported => {
|
||||
PrepareDiffSyntaxDocumentResult::Unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::view) fn prepare_diff_syntax_document_with_budget_reuse<'a, I>(
|
||||
language: DiffSyntaxLanguage,
|
||||
syntax_mode: DiffSyntaxMode,
|
||||
lines: I,
|
||||
budget: DiffSyntaxBudget,
|
||||
old_document: Option<PreparedDiffSyntaxDocument>,
|
||||
) -> PrepareDiffSyntaxDocumentResult
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
match syntax::prepare_treesitter_document_with_budget_reuse(
|
||||
language,
|
||||
syntax_mode,
|
||||
lines,
|
||||
budget,
|
||||
old_document.map(|document| document.inner),
|
||||
) {
|
||||
syntax::PrepareTreesitterDocumentResult::Ready(inner) => {
|
||||
PrepareDiffSyntaxDocumentResult::Ready(PreparedDiffSyntaxDocument { inner })
|
||||
}
|
||||
syntax::PrepareTreesitterDocumentResult::TimedOut => {
|
||||
PrepareDiffSyntaxDocumentResult::TimedOut
|
||||
}
|
||||
syntax::PrepareTreesitterDocumentResult::Unsupported => {
|
||||
PrepareDiffSyntaxDocumentResult::Unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::view) fn prepare_diff_syntax_document_in_background<'a, I>(
|
||||
language: DiffSyntaxLanguage,
|
||||
syntax_mode: DiffSyntaxMode,
|
||||
lines: I,
|
||||
) -> Option<BackgroundPreparedDiffSyntaxDocument>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
syntax::prepare_treesitter_document_in_background(language, syntax_mode, lines)
|
||||
.map(|inner| BackgroundPreparedDiffSyntaxDocument { inner })
|
||||
}
|
||||
|
||||
pub(in crate::view) fn inject_background_prepared_diff_syntax_document(
|
||||
document: BackgroundPreparedDiffSyntaxDocument,
|
||||
) -> PreparedDiffSyntaxDocument {
|
||||
PreparedDiffSyntaxDocument {
|
||||
inner: syntax::inject_prepared_document_data(document.inner),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::view) fn benchmark_diff_syntax_cache_replacement_drop_step(
|
||||
lines: usize,
|
||||
tokens_per_line: usize,
|
||||
replacements: usize,
|
||||
defer_drop: bool,
|
||||
) -> u64 {
|
||||
syntax::benchmark_cache_replacement_drop_step(lines, tokens_per_line, replacements, defer_drop)
|
||||
}
|
||||
|
||||
pub(in crate::view) fn benchmark_diff_syntax_cache_drop_payload_timed_step(
|
||||
lines: usize,
|
||||
tokens_per_line: usize,
|
||||
seed: usize,
|
||||
defer_drop: bool,
|
||||
) -> std::time::Duration {
|
||||
syntax::benchmark_drop_payload_timed_step(lines, tokens_per_line, seed, defer_drop)
|
||||
}
|
||||
|
||||
pub(in crate::view) fn benchmark_flush_diff_syntax_deferred_drop_queue() -> bool {
|
||||
syntax::benchmark_flush_deferred_drop_queue()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(in crate::view) struct PreparedDiffSyntaxCacheMetrics {
|
||||
pub hit: u64,
|
||||
pub miss: u64,
|
||||
pub evict: u64,
|
||||
pub chunk_build_ms: u64,
|
||||
}
|
||||
|
||||
pub(in crate::view) fn benchmark_reset_diff_syntax_prepared_cache_metrics() {
|
||||
syntax::benchmark_reset_prepared_syntax_cache_metrics();
|
||||
}
|
||||
|
||||
pub(in crate::view) fn benchmark_diff_syntax_prepared_cache_metrics()
|
||||
-> PreparedDiffSyntaxCacheMetrics {
|
||||
let (hit, miss, evict, chunk_build_ms) = syntax::benchmark_prepared_syntax_cache_metrics();
|
||||
PreparedDiffSyntaxCacheMetrics {
|
||||
hit,
|
||||
miss,
|
||||
evict,
|
||||
chunk_build_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::view) fn benchmark_diff_syntax_prepared_loaded_chunk_count(
|
||||
document: PreparedDiffSyntaxDocument,
|
||||
) -> Option<usize> {
|
||||
syntax::benchmark_prepared_syntax_loaded_chunk_count(document.inner)
|
||||
}
|
||||
|
||||
pub(in crate::view) fn benchmark_diff_syntax_prepared_cache_contains_document(
|
||||
document: PreparedDiffSyntaxDocument,
|
||||
) -> bool {
|
||||
syntax::benchmark_prepared_syntax_cache_contains_document(document.inner)
|
||||
}
|
||||
|
||||
fn maybe_expand_tabs(s: &str) -> SharedString {
|
||||
if !s.contains('\t') {
|
||||
return s.to_string().into();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -35,15 +35,18 @@ impl MainPaneView {
|
|||
this.worktree_preview_segments_cache.clear();
|
||||
}
|
||||
|
||||
let syntax_mode = if lines.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
let configured_syntax_mode = if lines.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
|
||||
DiffSyntaxMode::Auto
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
let language = this.worktree_preview_syntax_language;
|
||||
let syntax_document = language.and_then(|language| {
|
||||
prepare_diff_syntax_document(language, syntax_mode, lines.iter().map(String::as_str))
|
||||
});
|
||||
let syntax_document = this.worktree_preview_prepared_syntax_document();
|
||||
let syntax_mode = if syntax_document.is_some() {
|
||||
configured_syntax_mode
|
||||
} else {
|
||||
DiffSyntaxMode::HeuristicOnly
|
||||
};
|
||||
|
||||
let highlight_deleted_file = this.deleted_file_preview_abs_path().is_some();
|
||||
let highlight_new_file = this.untracked_worktree_preview_path().is_some()
|
||||
|
|
@ -594,4 +597,26 @@ mod tests {
|
|||
assert_eq!(parsed, Some(*tz), "round-trip failed for {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_preview_renderer_avoids_full_document_prepare_calls() {
|
||||
let source = include_str!("history.rs");
|
||||
let render_start = source
|
||||
.find("fn render_worktree_preview_rows")
|
||||
.expect("render_worktree_preview_rows should exist");
|
||||
let render_end = source[render_start..]
|
||||
.find("impl HistoryView")
|
||||
.map(|offset| render_start + offset)
|
||||
.expect("HistoryView impl should follow worktree preview renderer");
|
||||
let render_source = &source[render_start..render_end];
|
||||
|
||||
assert!(
|
||||
!render_source.contains("prepare_diff_syntax_document("),
|
||||
"row renderer should not build prepared syntax documents"
|
||||
);
|
||||
assert!(
|
||||
!render_source.contains("prepare_diff_syntax_document_with_budget("),
|
||||
"row renderer should not run full-document parse prep"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,8 +72,11 @@ mod status;
|
|||
|
||||
pub(crate) mod benchmarks;
|
||||
|
||||
pub(super) use diff_text::{
|
||||
DiffSyntaxLanguage, DiffSyntaxMode, diff_syntax_language_for_path, syntax_highlights_for_line,
|
||||
pub(in crate::view) use diff_text::{
|
||||
BackgroundPreparedDiffSyntaxDocument, DiffSyntaxBudget, DiffSyntaxLanguage, DiffSyntaxMode,
|
||||
PrepareDiffSyntaxDocumentResult, PreparedDiffSyntaxDocument, diff_syntax_language_for_path,
|
||||
inject_background_prepared_diff_syntax_document, prepare_diff_syntax_document_in_background,
|
||||
prepare_diff_syntax_document_with_budget_reuse, syntax_highlights_for_line,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue