fix: show revision-aware blame in diff buffers
Diff and snapshot sources were excluded before scheduling, and the separate diff renderer never painted attribution. Capture per-side Git provenance, preserve private object stores for historical tabs, and reuse the current-line worker and cache.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
- [x] improve: checkmark persistance should be preserved in a diff where a certain file hasn't changed.
|
||||
- this is to avoid that changing a single file will cause all reviewed files to drop the reviewed state, making it harder to spot what actually requires a re-review and what can be ignored.
|
||||
- [ ] bug: diff view - git blame doesn't show in diff buffers for some reason.
|
||||
- [x] bug: diff view - git blame doesn't show in diff buffers for some reason.
|
||||
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||
|
||||
@@ -12,7 +12,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
||||
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
||||
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
||||
- `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply.
|
||||
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched.
|
||||
- `blame`: owns current-line Git attribution against supplied in-memory text and a cancellable background worker. Historical buffers capture their exact Git store, commit, and side-specific path; private stores remain alive while snapshots or requests reference them. Git-backed jj uses an isolated temporary Git store; active refs/checkouts remain untouched.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
|
||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||
@@ -58,7 +58,7 @@ Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated orde
|
||||
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
|
||||
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
|
||||
|
||||
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame.
|
||||
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, path, and optional historical source; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Comparison provenance follows accepted snapshot/checkout/bookmark refreshes and is retained by immutable buffers independently of their original comparison. Historical queries use a private HEAD and object alternates, never live-file existence or the current checkout baseline. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Ordinary and split/unified diff rendering use only spare cells after the current logical line's final visible continuation and hide the label when the primary caret is offscreen. Disabling drops the worker.
|
||||
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||
|
||||
@@ -90,6 +90,8 @@ Shared-workspace parity checks compare ordinary and resolver diff geometry/conte
|
||||
|
||||
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
|
||||
|
||||
Blame checks cover selected historical commits rather than live HEAD, renamed/deleted paths, unsafe/nonregular sources, Git-backed non-colocated jj, and privately fetched objects surviving comparison/repository teardown until the final snapshot releases them. Render/session regressions cover both split/unified sides, stale side results, wrapping, narrow panes, detached historical tabs, and Working attribution. Live PTY smoke opens both historical sides and unified view after deleting the file from the checkout, observes the correct commit labels, opens a historical tab, and exits with unchanged HEAD and tracked state.
|
||||
|
||||
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
|
||||
|
||||
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
|
||||
|
||||
+2
-2
@@ -258,9 +258,9 @@ Text wraps at word boundaries where possible, with grapheme-safe breaks for long
|
||||
|
||||
## Current-line Git blame
|
||||
|
||||
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution.
|
||||
Inline blame is enabled by default in live file buffers and on the focused side of split/unified diffs. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Live Git-backed jj buffers use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit live attribution.
|
||||
|
||||
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
|
||||
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Historical sides use their captured commit and side-specific path, including renamed or deleted files no longer on disk; historical tabs retain that source and any privately fetched objects after the comparison closes. Frozen Working sides use their captured parent baseline rather than a later checkout. Missing, untracked, unborn, and non-repository live files have no indicator. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
|
||||
|
||||
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
|
||||
|
||||
|
||||
+118
-53
@@ -17,7 +17,7 @@ pub(crate) const LIMIT: usize = 16 * 1024 * 1024;
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
|
||||
type Request = (u64, u64, PathBuf, String);
|
||||
type Request = (u64, u64, PathBuf, String, Option<RevisionSource>);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LineBlame {
|
||||
@@ -27,6 +27,14 @@ pub(crate) struct LineBlame {
|
||||
pub(crate) uncommitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct RevisionSource {
|
||||
pub(crate) store: PathBuf,
|
||||
pub(crate) commit: String,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
request: Option<Request>,
|
||||
@@ -63,13 +71,16 @@ impl BlameWorker {
|
||||
if worker.stopped.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let (generation, id, path, contents) = state.request.take().unwrap();
|
||||
let (generation, id, path, contents, source) = state.request.take().unwrap();
|
||||
drop(state);
|
||||
let cancelled = || {
|
||||
worker.stopped.load(Ordering::Acquire)
|
||||
|| worker.generation.load(Ordering::Acquire) != generation
|
||||
};
|
||||
let result = read_file(&path, &contents, &cancelled);
|
||||
let result = match source {
|
||||
Some(source) => read_revision(&source, &contents, &cancelled),
|
||||
None => read_file(&path, &contents, &cancelled),
|
||||
};
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
@@ -84,7 +95,13 @@ impl BlameWorker {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> {
|
||||
pub(crate) fn request(
|
||||
&mut self,
|
||||
id: u64,
|
||||
path: PathBuf,
|
||||
contents: String,
|
||||
source: Option<RevisionSource>,
|
||||
) -> io::Result<()> {
|
||||
if contents.len() > LIMIT {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
@@ -101,7 +118,7 @@ impl BlameWorker {
|
||||
.generation
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.wrapping_add(1);
|
||||
state.request = Some((generation, id, path, contents));
|
||||
state.request = Some((generation, id, path, contents, source));
|
||||
state.result = None;
|
||||
self.shared.wake.notify_one();
|
||||
Ok(())
|
||||
@@ -279,19 +296,93 @@ pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
|
||||
fn oid(value: &str) -> bool {
|
||||
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
|
||||
#[cfg(unix)]
|
||||
fn read_revision(
|
||||
source: &RevisionSource,
|
||||
contents: &str,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> ResultFile {
|
||||
if contents.len() > LIMIT {
|
||||
return Err("Blame input exceeds 16 MiB".into());
|
||||
}
|
||||
if !oid(&source.commit) {
|
||||
return Err("Invalid blame commit".into());
|
||||
}
|
||||
crate::repository::safe_path(&source.path)?;
|
||||
let rows = contents.split_inclusive('\n').count();
|
||||
if rows == 0 || source.commit.bytes().all(|byte| byte == b'0') {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut objects = git(&source.store);
|
||||
objects.arg("--git-dir").arg(&source.store).args([
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--git-path",
|
||||
"objects",
|
||||
]);
|
||||
let objects = run(objects, &[], cancelled)?.ok_or("Cannot resolve blame object store")?;
|
||||
let private = Temporary::new()?;
|
||||
fs::create_dir(private.0.join("refs"))
|
||||
.and_then(|()| fs::create_dir_all(private.0.join("objects/info")))
|
||||
.map_err(|_| "Cannot prepare private blame repository")?;
|
||||
// Keep relative alternates anchored in their original object directory.
|
||||
let mut alternate = vec![b'"'];
|
||||
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
|
||||
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
|
||||
alternate.push(byte);
|
||||
} else {
|
||||
write!(&mut alternate, "\\{byte:03o}").unwrap();
|
||||
}
|
||||
}
|
||||
alternate.extend_from_slice(b"\"\n");
|
||||
fs::write(private.0.join("objects/info/alternates"), alternate)
|
||||
.and_then(|()| fs::write(private.0.join("HEAD"), format!("{}\n", source.commit)))
|
||||
.map_err(|_| "Cannot prepare blame parent")?;
|
||||
if source.commit.len() == 64 {
|
||||
fs::write(
|
||||
private.0.join("config"),
|
||||
"[core]\nrepositoryformatversion = 1\n[extensions]\nobjectFormat = sha256\n",
|
||||
)
|
||||
.map_err(|_| "Cannot prepare blame object format")?;
|
||||
}
|
||||
let command = || {
|
||||
let mut command = git(&private.0);
|
||||
command
|
||||
.arg("--git-dir")
|
||||
.arg(&private.0)
|
||||
.arg("--work-tree")
|
||||
.arg(&private.0);
|
||||
command
|
||||
};
|
||||
let mut present = command();
|
||||
present
|
||||
.args(["ls-tree", "-r", "-z", "--full-tree", "HEAD", "--"])
|
||||
.arg(&source.path);
|
||||
let Some(entry) = run(present, &[], cancelled)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(tab) = entry.iter().position(|&byte| byte == b'\t') else {
|
||||
return Ok(None);
|
||||
};
|
||||
if (!entry.starts_with(b"100644 blob ") && !entry.starts_with(b"100755 blob "))
|
||||
|| entry[tab + 1..].strip_suffix(b"\0") != Some(source.path.as_os_str().as_encoded_bytes())
|
||||
{
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(PathBuf::from(
|
||||
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
|
||||
))
|
||||
return Ok(None);
|
||||
}
|
||||
let mut blame = command();
|
||||
blame
|
||||
.args([
|
||||
"blame",
|
||||
"--incremental",
|
||||
"--no-textconv",
|
||||
"--contents",
|
||||
"-",
|
||||
"--",
|
||||
])
|
||||
.arg(&source.path);
|
||||
let output = run(blame, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
|
||||
parse(&output, rows).map(Some)
|
||||
}
|
||||
|
||||
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
|
||||
if contents.len() > LIMIT {
|
||||
return Err("Blame input exceeds 16 MiB".into());
|
||||
@@ -320,7 +411,6 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut temporary = None;
|
||||
let mut command = git(root);
|
||||
if root.join(".jj").exists() {
|
||||
let mut jj = Command::new("jj");
|
||||
@@ -352,44 +442,19 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
|
||||
if !oid(parent) {
|
||||
return Err("Invalid Jujutsu blame metadata".into());
|
||||
}
|
||||
let mut repo = root.join(".jj/repo");
|
||||
if repo.is_file() {
|
||||
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?;
|
||||
repo = root
|
||||
.join(".jj")
|
||||
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
|
||||
let store = crate::repository::store_for_jj(root)?;
|
||||
return read_revision(
|
||||
&RevisionSource {
|
||||
store,
|
||||
commit: parent.into(),
|
||||
path: relative.to_path_buf(),
|
||||
keepalive: None,
|
||||
},
|
||||
contents,
|
||||
cancelled,
|
||||
);
|
||||
}
|
||||
let store = repo.join("store");
|
||||
let target = fs::read(store.join("git_target"))
|
||||
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
|
||||
let store = fs::canonicalize(
|
||||
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
|
||||
)
|
||||
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
|
||||
let private = Temporary::new()?;
|
||||
fs::create_dir(private.0.join("refs"))
|
||||
.map_err(|_| "Cannot prepare private blame repository")?;
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
|
||||
.map_err(|_| "Cannot link blame objects")?;
|
||||
#[cfg(not(unix))]
|
||||
return Err("Jujutsu blame requires Unix object links".into());
|
||||
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
|
||||
.map_err(|_| "Cannot prepare blame parent")?;
|
||||
command
|
||||
.arg("--git-dir")
|
||||
.arg(&private.0)
|
||||
.arg("--work-tree")
|
||||
.arg(root);
|
||||
temporary = Some(private);
|
||||
}
|
||||
let mut present = if let Some(private) = &temporary {
|
||||
let mut command = git(root);
|
||||
command.arg("--git-dir").arg(&private.0);
|
||||
command
|
||||
} else {
|
||||
git(root)
|
||||
};
|
||||
let mut present = git(root);
|
||||
let mut object = std::ffi::OsString::from("HEAD:");
|
||||
object.push(relative);
|
||||
present.args(["cat-file", "-e"]).arg(object);
|
||||
|
||||
+11
@@ -4,6 +4,7 @@ use std::{
|
||||
borrow::Cow,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
@@ -141,10 +142,19 @@ pub struct ChangedFile {
|
||||
pub hunks: Vec<Hunk>,
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BlameRevision {
|
||||
pub store: PathBuf,
|
||||
pub commit: String,
|
||||
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Comparison {
|
||||
/// Immutable comparison-context IDs; captured review text also freezes live edits.
|
||||
pub revisions: [String; 2],
|
||||
/// Git object stores and immutable attribution baselines for Before/After.
|
||||
pub blame: [Option<BlameRevision>; 2],
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub files: Vec<ChangedFile>,
|
||||
@@ -670,6 +680,7 @@ impl DiffState {
|
||||
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
|
||||
let comparison = self.comparison.get_or_insert_with(|| Comparison {
|
||||
revisions: Default::default(),
|
||||
blame: Default::default(),
|
||||
before: "Resolution".into(),
|
||||
after: "Other version".into(),
|
||||
files: Vec::new(),
|
||||
|
||||
+15
-4
@@ -1952,6 +1952,7 @@ struct Buffer {
|
||||
resolver_readonly: bool,
|
||||
snapshot_document_path: Option<PathBuf>,
|
||||
snapshot_root: Option<PathBuf>,
|
||||
snapshot_blame: Option<Arc<crate::blame::RevisionSource>>,
|
||||
lines: Vec<String>,
|
||||
saved_lines: Vec<String>,
|
||||
// Invalidate whenever text or the saved baseline changes, including temporary swaps.
|
||||
@@ -2012,6 +2013,7 @@ impl Buffer {
|
||||
resolver_readonly: false,
|
||||
snapshot_document_path: None,
|
||||
snapshot_root: None,
|
||||
snapshot_blame: None,
|
||||
saved_lines: lines.clone(),
|
||||
dirty_cache: Cell::new(Some(false)),
|
||||
disk_stamp,
|
||||
@@ -2175,6 +2177,7 @@ pub(crate) struct BlameSource {
|
||||
buffer: usize,
|
||||
revision: u64,
|
||||
pub(crate) path: Arc<Path>,
|
||||
pub(crate) snapshot: Option<Arc<crate::blame::RevisionSource>>,
|
||||
pub(crate) row: usize,
|
||||
save_revision: u64,
|
||||
disk_stamp: Option<FileStamp>,
|
||||
@@ -5116,6 +5119,14 @@ impl Editor {
|
||||
};
|
||||
let source = comparison.root.join(relative);
|
||||
let root = comparison.root.clone();
|
||||
let blame = comparison.blame[side].as_ref().map(|baseline| {
|
||||
Arc::new(crate::blame::RevisionSource {
|
||||
store: baseline.store.clone(),
|
||||
commit: baseline.commit.clone(),
|
||||
keepalive: baseline.keepalive.clone(),
|
||||
path: relative.clone(),
|
||||
})
|
||||
});
|
||||
let lines = diff.snapshot_lines_at(file_index, focus);
|
||||
let key = (
|
||||
diff.comparison_id.expect("snapshot comparison"),
|
||||
@@ -5155,6 +5166,7 @@ impl Editor {
|
||||
buffer.path = Some(source);
|
||||
buffer.snapshot_document_path = Some(document_path);
|
||||
buffer.snapshot_root = Some(root);
|
||||
buffer.snapshot_blame = blame;
|
||||
buffer.lines = lines;
|
||||
buffer.dirty_cache.set(None);
|
||||
buffer
|
||||
@@ -7156,8 +7168,7 @@ impl Editor {
|
||||
pub(crate) fn blame_source(&self) -> Option<BlameSource> {
|
||||
if !self.git_blame
|
||||
|| self.should_quit()
|
||||
|| self.diff.is_some()
|
||||
|| self.active.diff_snapshot.is_some()
|
||||
|| (self.active.diff_snapshot.is_some() && self.active.snapshot_blame.is_none())
|
||||
|| self.active.reload_error.is_some()
|
||||
{
|
||||
return None;
|
||||
@@ -7166,6 +7177,7 @@ impl Editor {
|
||||
buffer: self.buffer_id(),
|
||||
revision: self.text_revision(),
|
||||
path: Arc::from(self.path()?),
|
||||
snapshot: self.active.snapshot_blame.clone(),
|
||||
row: self.cursor().row,
|
||||
save_revision: self.active.save_revision,
|
||||
disk_stamp: self.active.disk_stamp,
|
||||
@@ -7177,13 +7189,12 @@ impl Editor {
|
||||
pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool {
|
||||
self.git_blame
|
||||
&& !self.should_quit()
|
||||
&& self.diff.is_none()
|
||||
&& self.active.diff_snapshot.is_none()
|
||||
&& self.active.reload_error.is_none()
|
||||
&& source.buffer == self.buffer_id()
|
||||
&& source.revision == self.text_revision()
|
||||
&& source.save_revision == self.active.save_revision
|
||||
&& source.disk_stamp == self.active.disk_stamp
|
||||
&& source.snapshot == self.active.snapshot_blame
|
||||
&& Some(source.path.as_ref()) == self.path()
|
||||
}
|
||||
|
||||
|
||||
+70
-5
@@ -1,7 +1,7 @@
|
||||
//! Read-only active-repository discovery and isolated revision comparison.
|
||||
use crate::diff::{
|
||||
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, PullRequestPage,
|
||||
RepoKind, Revision, RevisionTarget, line_hunks,
|
||||
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
|
||||
PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -28,7 +28,8 @@ const WORKING_SNAPSHOT_LIMIT: usize = 512 * 1024 * 1024;
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Temporary(PathBuf);
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Temporary(PathBuf);
|
||||
impl Temporary {
|
||||
fn new() -> Result<Self, String> {
|
||||
for _ in 0..100 {
|
||||
@@ -1054,6 +1055,7 @@ struct PreparedComparison {
|
||||
selected: [Revision; 2],
|
||||
selected_ids: [Option<String>; 2],
|
||||
revisions: [String; 2],
|
||||
blame: [Option<BlameRevision>; 2],
|
||||
mode: ComparisonMode,
|
||||
left: Snapshot,
|
||||
right: Snapshot,
|
||||
@@ -1077,7 +1079,7 @@ pub struct Repository {
|
||||
root: PathBuf,
|
||||
kind: RepoKind,
|
||||
store: PathBuf,
|
||||
temporary: Temporary,
|
||||
temporary: Arc<Temporary>,
|
||||
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
|
||||
remotes: Vec<Remote>,
|
||||
remote_refs: BTreeMap<(String, String), String>,
|
||||
@@ -1128,7 +1130,7 @@ impl Repository {
|
||||
root,
|
||||
kind,
|
||||
store,
|
||||
temporary: Temporary::new()?,
|
||||
temporary: Arc::new(Temporary::new()?),
|
||||
private: None,
|
||||
remotes: vec![],
|
||||
remote_refs: BTreeMap::new(),
|
||||
@@ -2390,6 +2392,48 @@ impl Repository {
|
||||
}
|
||||
Ok(Some(text(checked(output)?)?.trim().into()))
|
||||
}
|
||||
fn blame_baseline(&mut self, target: &RevisionTarget) -> Result<Option<BlameRevision>, String> {
|
||||
let source = if *target == RevisionTarget::Working {
|
||||
let id = if self.kind == RepoKind::Git {
|
||||
self.working_anchor()?
|
||||
} else {
|
||||
let parents = text(self.jj(
|
||||
&self.root,
|
||||
&[
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id ++ \"\\n\"",
|
||||
],
|
||||
)?)?;
|
||||
let mut parents = parents.lines();
|
||||
let parent = parents.next();
|
||||
// A merge has no single attribution baseline.
|
||||
if parents.next().is_some() {
|
||||
None
|
||||
} else {
|
||||
parent.map(str::to_owned)
|
||||
}
|
||||
};
|
||||
id.map(|id| (self.store.clone(), id))
|
||||
} else {
|
||||
Some(self.resolve(target)?)
|
||||
};
|
||||
Ok(source
|
||||
.filter(|(_, id)| !id.bytes().all(|byte| byte == b'0'))
|
||||
.map(|(store, commit)| BlameRevision {
|
||||
keepalive: self
|
||||
.private
|
||||
.as_ref()
|
||||
.filter(|(_, private_store)| private_store == &store)
|
||||
.map(|_| Arc::clone(&self.temporary)),
|
||||
store,
|
||||
commit,
|
||||
}))
|
||||
}
|
||||
|
||||
fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
|
||||
if before == after || after.is_none() {
|
||||
return Ok(false);
|
||||
@@ -2492,11 +2536,16 @@ impl Repository {
|
||||
} else {
|
||||
self.snapshot_revision(after_target, &right)?
|
||||
};
|
||||
let blame = [
|
||||
self.blame_baseline(before_target)?,
|
||||
self.blame_baseline(after_target)?,
|
||||
];
|
||||
let mut prepared = PreparedComparison {
|
||||
id: comparison_id,
|
||||
selected: [before.clone(), after.clone()],
|
||||
selected_ids,
|
||||
revisions: [before_revision, after_revision],
|
||||
blame,
|
||||
mode,
|
||||
left,
|
||||
right,
|
||||
@@ -2614,10 +2663,12 @@ impl Repository {
|
||||
}
|
||||
let right = self.working_snapshot(buffers)?;
|
||||
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
|
||||
let blame = self.blame_baseline(&RevisionTarget::Working)?;
|
||||
let mut prepared = self.comparison.take().unwrap();
|
||||
let previous_right = std::mem::replace(&mut prepared.right, right);
|
||||
let previous_files = std::mem::take(&mut prepared.files);
|
||||
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision);
|
||||
let previous_blame = std::mem::replace(&mut prepared.blame[1], blame);
|
||||
let result = self
|
||||
.build_comparison(&mut prepared, false)
|
||||
.and_then(|comparison| {
|
||||
@@ -2640,6 +2691,7 @@ impl Repository {
|
||||
prepared.right = previous_right;
|
||||
prepared.files = previous_files;
|
||||
prepared.revisions[1] = previous_revision;
|
||||
prepared.blame[1] = previous_blame;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
|
||||
@@ -2671,13 +2723,16 @@ impl Repository {
|
||||
&& follow_working
|
||||
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
|
||||
let mut revisions = prepared.revisions.clone();
|
||||
let mut blame = prepared.blame.clone();
|
||||
let (left, before_label) = if branch {
|
||||
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
|
||||
let label = format!("merge base of {label} and {after_label} · {base}");
|
||||
revisions[0] = base.clone();
|
||||
blame[0] = self.blame_baseline(&RevisionTarget::Commit(base.clone()))?;
|
||||
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
|
||||
} else if changed_before {
|
||||
revisions[0] = ids[0].clone().unwrap();
|
||||
blame[0] = self.blame_baseline(&RevisionTarget::Commit(revisions[0].clone()))?;
|
||||
(
|
||||
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
|
||||
label,
|
||||
@@ -2699,6 +2754,13 @@ impl Repository {
|
||||
ids[1].clone().unwrap()
|
||||
};
|
||||
}
|
||||
if right.is_some() {
|
||||
blame[1] = self.blame_baseline(&if advance_working {
|
||||
RevisionTarget::Working
|
||||
} else {
|
||||
RevisionTarget::Commit(revisions[1].clone())
|
||||
})?;
|
||||
}
|
||||
let pinned_checkout = ids[1]
|
||||
.as_deref()
|
||||
.map(|id| self.pinned_checkout(id))
|
||||
@@ -2711,6 +2773,7 @@ impl Repository {
|
||||
let previous_before = std::mem::replace(&mut prepared.before, before_label);
|
||||
let previous_editable = prepared.editable_after;
|
||||
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
|
||||
let previous_blame = std::mem::replace(&mut prepared.blame, blame);
|
||||
prepared.editable_after =
|
||||
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
|
||||
let result = self
|
||||
@@ -2747,6 +2810,7 @@ impl Repository {
|
||||
prepared.before = previous_before;
|
||||
prepared.editable_after = previous_editable;
|
||||
prepared.revisions = previous_revisions;
|
||||
prepared.blame = previous_blame;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
result
|
||||
@@ -2861,6 +2925,7 @@ impl Repository {
|
||||
};
|
||||
Comparison {
|
||||
revisions: prepared.revisions.clone(),
|
||||
blame: prepared.blame.clone(),
|
||||
before: prepared.before.clone(),
|
||||
after: prepared.after.clone(),
|
||||
files,
|
||||
|
||||
@@ -1690,6 +1690,7 @@ impl EditorSession {
|
||||
self.blame_request_id,
|
||||
source.path.to_path_buf(),
|
||||
self.editor.lines().join("\n"),
|
||||
source.snapshot.as_deref().cloned(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
|
||||
+34
-16
@@ -2665,22 +2665,13 @@ fn draw_document(
|
||||
.find(|(_, (row, _, _))| *row == cursor.row)
|
||||
{
|
||||
let line = &editor.lines()[cursor.row];
|
||||
let end = display_column(line, usize::MAX);
|
||||
let start = end.saturating_sub(segment.start).saturating_add(2);
|
||||
let label_width = UnicodeWidthStr::width(label);
|
||||
if end >= segment.start
|
||||
&& end <= segment.end
|
||||
&& (!wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len())
|
||||
&& start.saturating_add(label_width) <= width
|
||||
{
|
||||
frame.render_widget(
|
||||
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
|
||||
Rect::new(
|
||||
viewport.x + start as u16,
|
||||
viewport.y + screen_row as u16,
|
||||
label_width as u16,
|
||||
1,
|
||||
),
|
||||
if !wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len() {
|
||||
paint_inline_blame(
|
||||
frame,
|
||||
label,
|
||||
line,
|
||||
Rect::new(viewport.x, viewport.y + screen_row as u16, width as u16, 1),
|
||||
segment.start,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3236,6 +3227,18 @@ fn paint_editor_line(
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_inline_blame(frame: &mut Frame, label: &str, line: &str, area: Rect, left: usize) {
|
||||
let end = display_column(line, usize::MAX);
|
||||
let start = end.saturating_sub(left).saturating_add(2);
|
||||
let label_width = UnicodeWidthStr::width(label);
|
||||
if end >= left && start.saturating_add(label_width) <= usize::from(area.width) {
|
||||
frame.render_widget(
|
||||
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
|
||||
Rect::new(area.x + start as u16, area.y, label_width as u16, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_diff_editor(
|
||||
frame: &mut Frame,
|
||||
editor: &Editor,
|
||||
@@ -3250,6 +3253,7 @@ fn paint_diff_editor(
|
||||
let source_cursor = editor.cursor();
|
||||
let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column);
|
||||
let mut cursor = None;
|
||||
let mut blame_area = None;
|
||||
let focus = view.info.focus;
|
||||
for (screen, display, segments) in
|
||||
diff_screen_rows(view.info.display_len, top, area.height, wrapped)
|
||||
@@ -3283,6 +3287,20 @@ fn paint_diff_editor(
|
||||
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
|
||||
}
|
||||
}
|
||||
if source == source_cursor.row
|
||||
&& (segments.is_none()
|
||||
|| wrapped.is_some_and(|rows| {
|
||||
rows[screen].final_segments[usize::from(focus == Focus::After)]
|
||||
}))
|
||||
{
|
||||
blame_area = Some((Rect::new(area.x, line_area.y, area.width, 1), left));
|
||||
}
|
||||
}
|
||||
if cursor.is_some()
|
||||
&& let Some((area, left)) = blame_area
|
||||
&& let Some(label) = editor.line_blame()
|
||||
{
|
||||
paint_inline_blame(frame, label, &editor.lines()[source_cursor.row], area, left);
|
||||
}
|
||||
cursor
|
||||
}
|
||||
|
||||
+71
-1
@@ -226,6 +226,13 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
|
||||
.unwrap();
|
||||
assert_eq!(first.revisions[0], id);
|
||||
assert!(first.revisions[1].starts_with("working:"));
|
||||
assert_eq!(first.blame[0].as_ref().unwrap().commit, id);
|
||||
assert_eq!(
|
||||
first.blame[1]
|
||||
.as_ref()
|
||||
.map(|baseline| baseline.commit.as_str()),
|
||||
(kind == RepoKind::Git).then_some(id.as_str())
|
||||
);
|
||||
let reopened = open(&root)
|
||||
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
|
||||
.unwrap();
|
||||
@@ -255,6 +262,10 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
|
||||
.unwrap();
|
||||
assert_eq!(working_pair.revisions[0], first.revisions[1]);
|
||||
assert_eq!(working_pair.revisions[0], working_pair.revisions[1]);
|
||||
assert_eq!(
|
||||
working_pair.blame,
|
||||
[first.blame[1].clone(), first.blame[1].clone()]
|
||||
);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
fs::write(root.join("file"), "working").unwrap();
|
||||
let no_newline = repository
|
||||
@@ -785,6 +796,8 @@ fn jj_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching
|
||||
panic!("Bookmark movement must replace contents even when follow is disabled");
|
||||
};
|
||||
assert_eq!(comparison.after, topic.label);
|
||||
assert!(comparison.blame[0].is_none());
|
||||
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, next);
|
||||
assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]);
|
||||
assert!(repository.load_file(1, 0).is_err());
|
||||
assert!(
|
||||
@@ -944,6 +957,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
|
||||
.unwrap();
|
||||
assert!(original.before.ends_with(&base));
|
||||
assert_eq!(original.revisions, [base.clone(), tip.clone()]);
|
||||
assert_eq!(original.blame[0].as_ref().unwrap().commit, base);
|
||||
assert_eq!(original.blame[1].as_ref().unwrap().commit, tip);
|
||||
jj(&root, &["bookmark", "set", "before", "-r", &tip]);
|
||||
let Some(CheckoutRefresh::Bookmark(comparison)) =
|
||||
repository.refresh_checkout(1, 2, false, &[]).unwrap()
|
||||
@@ -952,6 +967,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
|
||||
};
|
||||
assert!(comparison.before.ends_with(&tip));
|
||||
assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]);
|
||||
assert_eq!(comparison.blame[0].as_ref().unwrap().commit, tip);
|
||||
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, tip);
|
||||
assert!(comparison.files.is_empty());
|
||||
jj(&root, &["bookmark", "delete", "after"]);
|
||||
let state = disk_state(&root);
|
||||
@@ -1331,6 +1348,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.unwrap();
|
||||
assert_eq!(promoted.before, branch.before);
|
||||
assert_eq!(promoted.after, branch.after);
|
||||
assert_eq!(promoted.blame, branch.blame);
|
||||
assert!(promoted.editable_paths.contains(Path::new("file")));
|
||||
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
|
||||
assert_eq!(disk_state(&active), checked_out_state);
|
||||
@@ -2260,6 +2278,8 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.before, original.before);
|
||||
assert_eq!(refreshed.after, original.after);
|
||||
assert_eq!(refreshed.blame[0], original.blame[0]);
|
||||
assert_eq!(refreshed.blame[1].as_ref().unwrap().commit, tip);
|
||||
for (index, file) in refreshed.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(2, index).unwrap();
|
||||
}
|
||||
@@ -2526,13 +2546,14 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "original disk\n").unwrap();
|
||||
let mut repository = open(&root);
|
||||
compare_loaded(
|
||||
let initial = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&base, RepoKind::Jujutsu),
|
||||
&working(RepoKind::Jujutsu),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(initial.blame[1].as_ref().unwrap().commit, base);
|
||||
fs::write(root.join("file"), "same-change rewrite\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "rewritten"]);
|
||||
let state = disk_state(&root);
|
||||
@@ -2545,6 +2566,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
jj(&root, &["new"]);
|
||||
let parent = jj(&root, &["log", "--no-graph", "-r", "@-", "-T", "commit_id"]);
|
||||
fs::remove_file(root.join("removed")).unwrap();
|
||||
fs::write(root.join("added"), "new file\n").unwrap();
|
||||
let state = disk_state(&root);
|
||||
@@ -2553,6 +2575,8 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
.unwrap()
|
||||
.map(checkout_comparison)
|
||||
.unwrap();
|
||||
assert_eq!(comparison.blame[0], initial.blame[0]);
|
||||
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, parent);
|
||||
for (index, file) in comparison.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(2, index).unwrap();
|
||||
}
|
||||
@@ -2727,12 +2751,16 @@ fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_muta
|
||||
assert_eq!(branch.files.len(), 1);
|
||||
assert_eq!(branch.files[0].path, Path::new("remote-only"));
|
||||
assert!(branch.before.ends_with(&base));
|
||||
assert_eq!(branch.blame[0].as_ref().unwrap().commit, base);
|
||||
assert!(!branch.blame[1].as_ref().unwrap().store.starts_with(&active));
|
||||
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
|
||||
let reverse = repository
|
||||
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap();
|
||||
assert_eq!(reverse.files.len(), 1);
|
||||
assert_eq!(reverse.files[0].path, Path::new("local-only"));
|
||||
assert_eq!(reverse.blame[0].as_ref().unwrap().commit, base);
|
||||
assert_eq!(reverse.blame[1].as_ref().unwrap().commit, local.label);
|
||||
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
|
||||
assert!(
|
||||
repository
|
||||
@@ -2866,3 +2894,45 @@ fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
|
||||
assert_eq!(disk_state(&root), state);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_working_blame_captures_one_parent_and_never_selects_an_arbitrary_merge_parent() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("blame-parents");
|
||||
init_jj(&root, false);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "base"]);
|
||||
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&root, &["new"]);
|
||||
let state = disk_state(&root);
|
||||
let mut repository = open(&root);
|
||||
let one_parent = repository
|
||||
.compare(
|
||||
1,
|
||||
&working(RepoKind::Jujutsu),
|
||||
&working(RepoKind::Jujutsu),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(one_parent.blame[0].as_ref().unwrap().commit, base);
|
||||
assert_eq!(one_parent.blame[0], one_parent.blame[1]);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
jj(&root, &["new", "root()"]);
|
||||
fs::write(root.join("other"), "sibling\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "sibling"]);
|
||||
let sibling = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&root, &["new", &base, &sibling]);
|
||||
let state = disk_state(&root);
|
||||
let merged = repository
|
||||
.compare(
|
||||
2,
|
||||
&working(RepoKind::Jujutsu),
|
||||
&working(RepoKind::Jujutsu),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged.blame, [None, None]);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
}
|
||||
|
||||
+255
-2
@@ -29,6 +29,26 @@ fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn disk_state(root: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
|
||||
fn visit(root: &Path, path: &Path, state: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>) {
|
||||
for entry in fs::read_dir(path).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let metadata = fs::symlink_metadata(&path).unwrap();
|
||||
if metadata.is_dir() {
|
||||
visit(root, &path, state);
|
||||
} else if metadata.is_file() {
|
||||
state.insert(
|
||||
path.strip_prefix(root).unwrap().to_path_buf(),
|
||||
fs::read(path).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut state = std::collections::BTreeMap::new();
|
||||
visit(root, root, &mut state);
|
||||
state
|
||||
}
|
||||
|
||||
fn fixture() -> (Temporary, PathBuf, String) {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
@@ -150,6 +170,239 @@ fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn historical_git_and_jj_blame_survives_renamed_and_deleted_paths() {
|
||||
for is_jj in [false, true] {
|
||||
let (temp, old_path, original) = fixture();
|
||||
if is_jj {
|
||||
command(&temp.0, "jj", &["git", "init", "--colocate", "."]);
|
||||
command(&temp.0, "jj", &["new", &original]);
|
||||
}
|
||||
fs::write(&old_path, "historical\nsecond\n").unwrap();
|
||||
let selected = if is_jj {
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&["describe", "-m", "Selected historical lines"],
|
||||
);
|
||||
let id = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
||||
);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
id
|
||||
} else {
|
||||
command(&temp.0, "git", &["add", "."]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "Selected historical lines"],
|
||||
);
|
||||
command(&temp.0, "git", &["rev-parse", "HEAD"])
|
||||
};
|
||||
let renamed = temp.0.join("renamed.txt");
|
||||
fs::rename(&old_path, &renamed).unwrap();
|
||||
let renamed_commit = if is_jj {
|
||||
command(&temp.0, "jj", &["describe", "-m", "Rename"]);
|
||||
let id = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
||||
);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
id
|
||||
} else {
|
||||
command(&temp.0, "git", &["add", "-A"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Rename"]);
|
||||
command(&temp.0, "git", &["rev-parse", "HEAD"])
|
||||
};
|
||||
fs::remove_file(&renamed).unwrap();
|
||||
if is_jj {
|
||||
command(&temp.0, "jj", &["describe", "-m", "Delete"]);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
} else {
|
||||
command(&temp.0, "git", &["add", "-A"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Delete"]);
|
||||
}
|
||||
let store = if is_jj {
|
||||
crate::repository::store_for_jj(&temp.0).unwrap()
|
||||
} else {
|
||||
temp.0.join(".git")
|
||||
};
|
||||
let state = disk_state(&temp.0);
|
||||
for (commit, path) in [
|
||||
(
|
||||
selected.clone(),
|
||||
old_path.strip_prefix(&temp.0).unwrap().to_path_buf(),
|
||||
),
|
||||
(renamed_commit, PathBuf::from("renamed.txt")),
|
||||
] {
|
||||
let source = RevisionSource {
|
||||
store: store.clone(),
|
||||
commit,
|
||||
path,
|
||||
keepalive: None,
|
||||
};
|
||||
let mut worker = BlameWorker::start().unwrap();
|
||||
worker
|
||||
.request(
|
||||
1,
|
||||
old_path.clone(),
|
||||
"historical\nsecond\n".into(),
|
||||
Some(source),
|
||||
)
|
||||
.unwrap();
|
||||
let started = Instant::now();
|
||||
let rows = loop {
|
||||
if let Some((id, result)) = worker.poll() {
|
||||
assert_eq!(id, 1);
|
||||
break result.unwrap().unwrap();
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(20),
|
||||
"Historical blame stalled"
|
||||
);
|
||||
thread::yield_now();
|
||||
};
|
||||
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
|
||||
assert_eq!(
|
||||
rows[0].as_ref().unwrap().summary,
|
||||
"Selected historical lines"
|
||||
);
|
||||
assert!(!rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||
}
|
||||
assert!(!old_path.exists() && !renamed.exists());
|
||||
assert_eq!(disk_state(&temp.0), state);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privately_fetched_git_and_noncolocated_jj_sources_keep_selected_attribution() {
|
||||
use crate::diff::{Category, ComparisonMode, Revision, RevisionTarget};
|
||||
let (remote, remote_path, original) = fixture();
|
||||
fs::write(&remote_path, "private historical\nsecond\n").unwrap();
|
||||
command(&remote.0, "git", &["add", "."]);
|
||||
command(
|
||||
&remote.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "Private selected"],
|
||||
);
|
||||
let selected = command(&remote.0, "git", &["rev-parse", "HEAD"]);
|
||||
let reference = command(&remote.0, "git", &["symbolic-ref", "HEAD"]);
|
||||
for is_jj in [false, true] {
|
||||
let (active, active_path, _) = fixture();
|
||||
fs::remove_file(&active_path).unwrap();
|
||||
if is_jj {
|
||||
fs::remove_dir_all(active.0.join(".git")).unwrap();
|
||||
command(&active.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||
command(
|
||||
&active.0,
|
||||
"jj",
|
||||
&["git", "remote", "add", "origin", remote.0.to_str().unwrap()],
|
||||
);
|
||||
} else {
|
||||
command(
|
||||
&active.0,
|
||||
"git",
|
||||
&["remote", "add", "origin", remote.0.to_str().unwrap()],
|
||||
);
|
||||
}
|
||||
let revision = |id: &str| Revision {
|
||||
target: RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: reference.clone(),
|
||||
id: id.into(),
|
||||
},
|
||||
label: id.into(),
|
||||
description: String::new(),
|
||||
category: Category::Commits,
|
||||
};
|
||||
let state = disk_state(&active.0);
|
||||
let mut repository =
|
||||
crate::repository::Repository::open(&active.0, Arc::new(AtomicBool::new(false)))
|
||||
.unwrap();
|
||||
let comparison = repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&original),
|
||||
&revision(&selected),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let baseline = comparison.blame[1].as_ref().unwrap();
|
||||
assert_eq!(baseline.commit, selected);
|
||||
assert!(!baseline.store.starts_with(&active.0));
|
||||
let source = RevisionSource {
|
||||
store: baseline.store.clone(),
|
||||
commit: baseline.commit.clone(),
|
||||
path: remote_path.strip_prefix(&remote.0).unwrap().to_path_buf(),
|
||||
keepalive: baseline.keepalive.clone(),
|
||||
};
|
||||
let store = source.store.clone();
|
||||
drop(comparison);
|
||||
drop(repository);
|
||||
assert!(
|
||||
store.exists(),
|
||||
"Snapshot provenance must retain private fetched objects"
|
||||
);
|
||||
let rows = read_revision(&source, "private historical\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||
assert!(!rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(disk_state(&active.0), state);
|
||||
drop(source);
|
||||
assert!(
|
||||
!store.exists(),
|
||||
"Last snapshot release must remove private objects"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_sources_reject_unsafe_paths_and_skip_missing_or_nonregular_baselines() {
|
||||
let (temp, path, commit) = fixture();
|
||||
let mut source = RevisionSource {
|
||||
store: temp.0.join(".git"),
|
||||
commit,
|
||||
path: "not-in-baseline".into(),
|
||||
keepalive: None,
|
||||
};
|
||||
assert_eq!(read_revision(&source, "new\n", &|| false).unwrap(), None);
|
||||
for path in ["", "../file", "/outside", ".git/config", ".jj/repo"] {
|
||||
source.path = path.into();
|
||||
assert!(read_revision(&source, "new\n", &|| false).is_err());
|
||||
}
|
||||
std::os::unix::fs::symlink(&path, temp.0.join("link")).unwrap();
|
||||
fs::create_dir(temp.0.join("nested")).unwrap();
|
||||
fs::write(temp.0.join("nested/file"), "nested\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "link", "nested/file"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Link"]);
|
||||
source.commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
source.path = "link".into();
|
||||
assert_eq!(read_revision(&source, "target\n", &|| false).unwrap(), None);
|
||||
source.path = "nested".into();
|
||||
assert_eq!(read_revision(&source, "nested\n", &|| false).unwrap(), None);
|
||||
source.path = "nested/file".into();
|
||||
let nested = read_revision(&source, "nested\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(nested[0].as_ref().unwrap().commit, source.commit);
|
||||
source.path = path.strip_prefix(&temp.0).unwrap().into();
|
||||
assert_eq!(
|
||||
read_revision(&source, "first\n", &|| true).unwrap_err(),
|
||||
"Blame cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
read_revision(&source, &"x".repeat(LIMIT + 1), &|| false).unwrap_err(),
|
||||
"Blame input exceeds 16 MiB"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
@@ -332,7 +585,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||
let mut worker = BlameWorker::start().unwrap();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(id, path.clone(), "first\nsecond\n".into())
|
||||
.request(id, path.clone(), "first\nsecond\n".into(), None)
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
@@ -351,7 +604,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(20, path, "x".repeat(LIMIT + 1))
|
||||
.request(20, path, "x".repeat(LIMIT + 1), None)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
|
||||
@@ -780,6 +780,7 @@ fn choose_pair(editor: &mut Editor) -> u64 {
|
||||
|
||||
fn comparison() -> Comparison {
|
||||
Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["abcdef123".into(), "working:snapshot".into()],
|
||||
before: "abcdef123".into(),
|
||||
after: "Working checkout".into(),
|
||||
@@ -1774,6 +1775,7 @@ fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() {
|
||||
assert!(editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["working:snapshot".into(), "working:snapshot".into()],
|
||||
before: "Working".into(),
|
||||
after: "Working".into(),
|
||||
@@ -1806,6 +1808,7 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
|
||||
Category::Commits,
|
||||
));
|
||||
state.comparison = Some(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["base".into(), "working:snapshot".into()],
|
||||
root: PathBuf::new(),
|
||||
before: "base".into(),
|
||||
|
||||
@@ -1879,6 +1879,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
|
||||
assert!(editor.apply_diff_result(
|
||||
comparison_request,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
root: editor.project_root().to_owned(),
|
||||
revisions: ["base".into(), "working-snapshot".into()],
|
||||
before: "base".into(),
|
||||
@@ -3068,6 +3069,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
|
||||
assert!(editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
root: root.canonicalize().unwrap(),
|
||||
revisions: ["base".into(), "working-snapshot".into()],
|
||||
before: "base".into(),
|
||||
@@ -3105,6 +3107,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
root: root.to_owned(),
|
||||
revisions: ["base".into(), "head".into()],
|
||||
before: "base".into(),
|
||||
@@ -4108,6 +4111,7 @@ fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives(
|
||||
assert!(editor.apply_diff_result(
|
||||
refresh,
|
||||
Ok(Outcome::BookmarkRefresh(Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
revisions: ["base".into(), "topic-snapshot".into()],
|
||||
before: "base".into(),
|
||||
@@ -4229,6 +4233,7 @@ fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_n
|
||||
let loaded_after = target.after.clone();
|
||||
target.loaded = false;
|
||||
let replacement = crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
revisions: ["base".into(), "ahead-snapshot".into()],
|
||||
before: "base".into(),
|
||||
|
||||
@@ -588,6 +588,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["base".into(), "after".into()],
|
||||
root: fixture.0.clone(),
|
||||
before: "base".into(),
|
||||
@@ -3721,6 +3722,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
blame: Default::default(),
|
||||
revisions: ["base".into(), "working:snapshot".into()],
|
||||
root: fixture.0.canonicalize().unwrap(),
|
||||
before: "base".into(),
|
||||
|
||||
+227
-5
@@ -2593,6 +2593,7 @@ fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
|
||||
diff.apply_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: std::path::PathBuf::new(),
|
||||
editable_paths: Default::default(),
|
||||
before: "old revision".into(),
|
||||
@@ -4805,6 +4806,7 @@ fn immutable_diff_draw_path_preserves_document_until_explicit_close() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: std::path::PathBuf::new(),
|
||||
editable_paths: Default::default(),
|
||||
before: "old revision".into(),
|
||||
@@ -5652,6 +5654,7 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -5745,6 +5748,7 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: ["live.rs".into()].into(),
|
||||
before: "baseline".into(),
|
||||
@@ -6377,6 +6381,7 @@ fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -6455,6 +6460,218 @@ fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
|
||||
assert!(titles.contains("2 [No Name]"), "{titles}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_blame_is_visible_in_focused_diff_buffers() {
|
||||
for unified in [false, true] {
|
||||
for before in [false, true] {
|
||||
let mut editor = readonly_diff_editor(deletion_file());
|
||||
if unified {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
if editor.diff_buffer_focus() != Some(if before { Focus::Before } else { Focus::After })
|
||||
{
|
||||
editor.handle(Input::Char('\t'));
|
||||
}
|
||||
let source = editor.blame_source().unwrap();
|
||||
let blame = Arc::new(crate::blame::LineBlame {
|
||||
commit: "0123456789abcdef".into(),
|
||||
author: "Author".into(),
|
||||
summary: "Original".into(),
|
||||
uncommitted: false,
|
||||
});
|
||||
editor.set_line_blame(source.clone(), blame.clone());
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let screen = screen_text(&terminal);
|
||||
assert!(screen.contains("Author · Original (01234567)"), "{screen}");
|
||||
let caret = terminal.get_cursor_position().unwrap();
|
||||
assert!(pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Author"));
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert!(!editor.set_line_blame(source, blame));
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("Author · Original"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_blame_uses_final_wrap_and_survives_a_historical_tab() {
|
||||
for unified in [false, true] {
|
||||
let text = "x".repeat(200);
|
||||
let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", &text, "after"));
|
||||
if unified {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.execute("word_wrap.toggle");
|
||||
let source = editor.blame_source().unwrap();
|
||||
let blame = Arc::new(crate::blame::LineBlame {
|
||||
commit: String::new(),
|
||||
author: String::new(),
|
||||
summary: String::new(),
|
||||
uncommitted: true,
|
||||
});
|
||||
editor.set_line_blame(source.clone(), blame.clone());
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 14)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let caret = terminal.get_cursor_position().unwrap();
|
||||
let screen = screen_text(&terminal);
|
||||
assert_eq!(screen.matches("Uncommitted").count(), 1, "{screen}");
|
||||
assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Uncommitted"));
|
||||
let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 14)).unwrap();
|
||||
narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap();
|
||||
assert!(!screen_text(&narrow).contains("Uncommitted"));
|
||||
editor.execute("tab.new");
|
||||
assert!(editor.diff().is_none());
|
||||
if !editor.word_wrap() {
|
||||
editor.execute("word_wrap.toggle");
|
||||
}
|
||||
editor.set_cached_line_blame(&source, blame);
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Uncommitted"));
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Char('z'));
|
||||
assert_eq!(editor.lines().join("\n"), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_blame_loads_real_historical_and_working_diff_sources() {
|
||||
let directory = Directory::new();
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git")
|
||||
.current_dir(&directory.0)
|
||||
.args(args)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_AUTHOR_NAME", "Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "author@example.test")
|
||||
.env("GIT_COMMITTER_NAME", "Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "author@example.test")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
};
|
||||
git(&["init", "-q", "--template="]);
|
||||
let path = directory.file("file.txt", "original\n");
|
||||
git(&["add", "file.txt"]);
|
||||
git(&["commit", "-qm", "Original"]);
|
||||
let original = git(&["rev-parse", "HEAD"]);
|
||||
directory.file("file.txt", "historical\n");
|
||||
git(&["commit", "-qam", "Historical"]);
|
||||
let historical = git(&["rev-parse", "HEAD"]);
|
||||
git(&["rm", "-q", "file.txt"]);
|
||||
git(&["commit", "-qm", "Deleted"]);
|
||||
let config = directory.file(
|
||||
".git/vamp-config.rb",
|
||||
"Vamp.configure { |c| c.git_gutter = 'none' }",
|
||||
);
|
||||
let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap();
|
||||
let ready = |session: &mut EditorSession, predicate: &dyn Fn(&Editor) -> bool| {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !predicate(session.editor()) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"message={:?}, state={:?}, blame={:?}, text={:?}",
|
||||
session.editor().message(),
|
||||
session.editor().diff().map(|diff| (
|
||||
diff.phase,
|
||||
diff.focus,
|
||||
diff.file,
|
||||
&diff.error
|
||||
)),
|
||||
session.editor().line_blame(),
|
||||
session.editor().lines()
|
||||
);
|
||||
session.poll();
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
};
|
||||
session.execute("diff");
|
||||
ready(&mut session, &|editor| {
|
||||
editor.diff().is_some_and(|diff| diff.catalog.is_some())
|
||||
});
|
||||
for revision in [&original, &historical] {
|
||||
for ch in revision.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
ready(&mut session, &|editor| {
|
||||
editor
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.selected_file_loaded())
|
||||
});
|
||||
session.handle(Input::Char('\t'));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
for expected in ["Original", "Historical"] {
|
||||
ready(&mut session, &|editor| {
|
||||
editor
|
||||
.line_blame()
|
||||
.is_some_and(|label| label.contains(expected))
|
||||
});
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
let screen = screen_text(&terminal);
|
||||
assert!(screen.contains(&format!("Author · {expected}")), "{screen}");
|
||||
assert!(!screen.contains("Uncommitted"), "{screen}");
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
assert!(!path.exists());
|
||||
session.execute("diff.close");
|
||||
directory.file("file.txt", "restored\n");
|
||||
git(&["add", "file.txt"]);
|
||||
git(&["commit", "-qm", "Restored"]);
|
||||
directory.file("file.txt", "unsaved baseline\n");
|
||||
session.editor_mut().refresh_files();
|
||||
session.execute("diff");
|
||||
ready(&mut session, &|editor| {
|
||||
editor.diff().is_some_and(|diff| diff.catalog.is_some())
|
||||
});
|
||||
for ch in git(&["rev-parse", "HEAD"]).chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Enter);
|
||||
ready(&mut session, &|editor| {
|
||||
editor
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.selected_file_loaded())
|
||||
});
|
||||
session.handle(Input::Char('\t'));
|
||||
session.handle(Input::Char('\t'));
|
||||
ready(&mut session, &|editor| {
|
||||
editor.line_blame() == Some("Uncommitted")
|
||||
});
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Uncommitted"));
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "unsaved baseline\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() {
|
||||
let directory = Directory::new();
|
||||
@@ -6545,11 +6762,6 @@ fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() {
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("Uncommitted"));
|
||||
let mut historical = readonly_diff_editor(deletion_file());
|
||||
assert!(historical.blame_source().is_none());
|
||||
historical.execute("tab.new");
|
||||
assert!(historical.diff().is_none());
|
||||
assert!(historical.blame_source().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7782,6 +7994,7 @@ fn deferred_lsp_definition_centers_only_successful_current_diff_results() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -7880,6 +8093,13 @@ fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: std::array::from_fn(|_| {
|
||||
Some(crate::diff::BlameRevision {
|
||||
store: editor.project_root().join(".git"),
|
||||
commit: "0123456789abcdef0123456789abcdef01234567".into(),
|
||||
keepalive: None,
|
||||
})
|
||||
}),
|
||||
root: editor.project_root().to_path_buf(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
@@ -8732,6 +8952,7 @@ fn diff_semantic_colors_stay_buffer_local_across_focus_changes() {
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: if writable {
|
||||
[std::path::PathBuf::from("live.rs")].into()
|
||||
@@ -8862,6 +9083,7 @@ fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt(
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
blame: Default::default(),
|
||||
root: directory.0.clone(),
|
||||
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
|
||||
before: "baseline".into(),
|
||||
|
||||
Reference in New Issue
Block a user