optimize diff flows

This commit is contained in:
Sampo Kivistö 2026-03-12 12:35:47 +02:00
parent c0d47d2ba8
commit 38146484a5
No known key found for this signature in database
GPG key ID: 3B426F446F481CFF
42 changed files with 14065 additions and 1577 deletions

2
.gitignore vendored
View file

@ -13,3 +13,5 @@ tmp/*
feat/*
.autoresearch/*
dist/
__pycache__/
*.pyc

View file

@ -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);
}
}

View file

@ -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",

View file

@ -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();

View file

@ -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)
}

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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]

View file

@ -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(

View file

@ -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 {

View file

@ -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);

View file

@ -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

View 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());
}
}
}

View file

@ -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

View file

@ -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,
);

View file

@ -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;

View file

@ -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());
}

View file

@ -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::{

View file

@ -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());

View file

@ -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,

View file

@ -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;
}

View file

@ -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;
};

View file

@ -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,

View file

@ -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);

View file

@ -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;

View file

@ -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(&current);
let next = conflict_resolver::append_lines_to_output(&current, &[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(&current);
let next =
conflict_resolver::append_lines_to_output(&current, 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(&current);
let next = conflict_resolver::append_lines_to_output(&current, &[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);
}
}

View file

@ -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

View file

@ -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: &[],

View file

@ -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 {

View file

@ -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>,

View file

@ -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();
}
}

View file

@ -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![

View file

@ -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

View file

@ -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>,

View file

@ -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);

View file

@ -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

View file

@ -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"
);
}
}

View file

@ -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)]