feat: add local diff review workflow
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
- [ ] diff view: local review UX first, independently of GitHub integration, for reviewing local changes and delivering feedback to coding agents.
|
- [ ] we should be able to execute commands in visual mode, since some commands may relevant to selections.
|
||||||
|
- [x] diff view: local review UX first, independently of GitHub integration, for reviewing local changes and delivering feedback to coding agents.
|
||||||
- add comments at the caret or on selected line ranges on either diff side; compose/edit with normal editor interaction, delete drafts, navigate comments, and open a review overview.
|
- add comments at the caret or on selected line ranges on either diff side; compose/edit with normal editor interaction, delete drafts, navigate comments, and open a review overview.
|
||||||
- start with gutter markers and a focused comment panel rather than inserting comment text into source rows; iterate on the interaction before adding remote operations.
|
- start with gutter markers and a focused comment panel rather than inserting comment text into source rows; iterate on the interaction before adding remote operations.
|
||||||
- support a review summary and local verdict: Looks good / Changes requested.
|
- support a review summary and local verdict: Looks good / Changes requested.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
|||||||
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.
|
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.
|
- `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.
|
||||||
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
|
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
|
||||||
|
- `review`: owns versioned local JSON review documents, immutable source anchors, validation, bounded reads, and atomic writes with external-change protection. It has no terminal or network operations. Comparison state owns the active review; `editor_review` keeps native comment/summary buffers and the read-only overview attached to their owning tab, synchronizes drafts on review commands, and guards unsaved teardown. Working snapshot IDs hash the private captured paths, modes, contents, and notices; anchors also retain exact file text to account for live edits without reanchoring.
|
||||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
|
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
|
||||||
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
|
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co
|
|||||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||||
| Normal | `follow_link` |
|
| Normal | `follow_link` |
|
||||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
||||||
|
| Normal, Visual | `diff.comment` |
|
||||||
|
| Normal | `diff.review.edit`, `diff.review.delete`, `diff.review.next`, `diff.review.previous`, `diff.review.overview`, `diff.review.summary`, `diff.review.verdict`, `diff.review.save`, `diff.review.open` |
|
||||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||||
| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` |
|
| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` |
|
||||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||||
@@ -312,6 +314,18 @@ Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work
|
|||||||
|
|
||||||
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
|
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
|
||||||
|
|
||||||
|
Local comments use `config.core.diff.comment` in Normal or Visual mode; the remaining `config.core.diff.review` actions in the table above use Normal mode. They have no default bindings and are available through the palette and canonical Ex commands. `edit`, `delete`, `verdict`, `save`, and `open` open argument prompts when selected as actions. For example:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
config.bind "leader r c", config.core.diff.comment
|
||||||
|
config.bind "leader r c", config.core.diff.comment, mode: config.modes.visual
|
||||||
|
config.bind "leader r o", config.core.diff.review.overview
|
||||||
|
config.bind "leader r n", config.core.diff.review.next
|
||||||
|
```
|
||||||
|
|
||||||
|
Comment and summary composition uses an ordinary native split pane. `:w` there writes the whole local JSON review, including hidden draft text; choose its destination with `:diff.review.save path`. These actions do not publish reviews or contact a forge. See [local review behavior](editing.md#repository-diff).
|
||||||
|
|
||||||
|
|
||||||
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
|
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
|
||||||
|
|
||||||
Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available.
|
Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available.
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
|
|||||||
|
|
||||||
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
|
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
|
||||||
|
|
||||||
|
Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes.
|
||||||
|
|
||||||
|
|
||||||
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
|
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
|
||||||
|
|
||||||
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
|
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
|
||||||
|
|||||||
@@ -449,6 +449,30 @@ Collapsed folders show a green **`✓`** when every changed file beneath them is
|
|||||||
|
|
||||||
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
|
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
|
||||||
|
|
||||||
|
**Local review comments** are independent of GitHub and the file-reviewed checkmarks above. Focus either diff text pane and run **`:diff.comment`** to comment at the caret. For a range, make one Visual or Visual Line selection, then choose `diff.comment` through Ctrl+p or a configured Visual-mode binding. Multiple independent carets/ranges must be reviewed separately.
|
||||||
|
|
||||||
|
Comments and summaries open in a focused native split pane, with their own heading, normal modes, selections, undo/redo, and pane-focus bindings. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify current comment ranges; **`●`** identifies the selected comment. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
|
||||||
|
|
||||||
|
| Command | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `:diff.comment` | Add a comment on the current side and caret/selected line range |
|
||||||
|
| `:diff.review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment |
|
||||||
|
| `:diff.review.delete [id]` | Delete that draft comment |
|
||||||
|
| `:diff.review.next` / `:diff.review.previous` | Cycle comments, opening their text and revealing a matching source anchor |
|
||||||
|
| `:diff.review.overview` | Refresh/open a read-only summary and comment list; Enter edits the entry under the caret |
|
||||||
|
| `:diff.review.summary` | Compose the review summary |
|
||||||
|
| `:diff.review.verdict looks_good` | Record **Looks good** |
|
||||||
|
| `:diff.review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
|
||||||
|
| `:diff.review.save [path]` | Save the entire review as local JSON; the first save requires a path |
|
||||||
|
| `:diff.review.open path` | Reopen a saved review for the current repository |
|
||||||
|
|
||||||
|
In a review text pane, **`:w` saves the entire review**, not a source file. Choose a destination with `:diff.review.save path` first, or use `:w path`. Saving includes edits in hidden comment/summary buffers. Normal close, quit, restart, comparison replacement, and source-buffer deletion protect unsaved reviews; forced close/quit retains its explicit discard semantics. An externally changed review file is never silently overwritten, including with `:w!`; use a new path or reopen it after preserving your edits.
|
||||||
|
|
||||||
|
The versioned JSON is the canonical, agent-readable artifact: repository root, summary, optional verdict, and comments with stable IDs, bodies, and anchors. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), inclusive one-based line range, and exact whole-file text including the final newline. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes any live unsaved edits. The file is limited to 64 MiB. Prefer saving outside the checkout so the review itself does not become another changed file.
|
||||||
|
|
||||||
|
Reopening requires the same repository root. Comments whose revision, path, or exact file text no longer matches are **stale**: their original anchors remain unchanged and their gutter markers disappear. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no automatic reanchoring, server, authentication, or review-publishing operation.
|
||||||
|
|
||||||
|
|
||||||
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
||||||
|
|
||||||
**Scope labels** identify named enclosing functions/classes and containers, such as `impl Review › run`. Muted, unnumbered rows supply context when the declaration is vertically outside that pane's viewport; scrolling it back into view removes the label and reclaims its row. If an unwrapped declaration remains vertically visible but its symbol is clipped horizontally, its label instead overlays the preceding content row. On the first visible row, that label is hidden so the pane border stays intact. Horizontal scrolling never inserts a row or shifts source text; scrolling the symbol back into view restores the covered row. Decorators alone do not count as the declaration name. Visibility follows source columns and wrapped segments independently for each side. Each split side uses its own source, so renamed functions keep distinct old/new names. Unified labels use `−` for Before, `+` for After, and `±` for shared names. Narrow panes prefer the innermost name. Labels are presentation only: motions, selections, copying, editing, and line jumps retain original source coordinates. Context comes from the existing Tree-sitter trees, including supported injected code, without requiring an LSP; unsupported syntax gets no guessed label.
|
**Scope labels** identify named enclosing functions/classes and containers, such as `impl Review › run`. Muted, unnumbered rows supply context when the declaration is vertically outside that pane's viewport; scrolling it back into view removes the label and reclaims its row. If an unwrapped declaration remains vertically visible but its symbol is clipped horizontally, its label instead overlays the preceding content row. On the first visible row, that label is hidden so the pane border stays intact. Horizontal scrolling never inserts a row or shifts source text; scrolling the symbol back into view restores the covered row. Decorators alone do not count as the declaration name. Visibility follows source columns and wrapped segments independently for each side. Each split side uses its own source, so renamed functions keep distinct old/new names. Unified labels use `−` for Before, `+` for After, and `±` for shared names. Narrow panes prefer the innermost name. Labels are presentation only: motions, selections, copying, editing, and line jumps retain original source coordinates. Context comes from the existing Tree-sitter trees, including supported injected code, without requiring an LSP; unsupported syntax gets no guessed label.
|
||||||
|
|||||||
+79
@@ -139,6 +139,8 @@ pub struct ChangedFile {
|
|||||||
}
|
}
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Comparison {
|
pub struct Comparison {
|
||||||
|
/// Immutable comparison-context IDs; captured review text also freezes live edits.
|
||||||
|
pub revisions: [String; 2],
|
||||||
pub before: String,
|
pub before: String,
|
||||||
pub after: String,
|
pub after: String,
|
||||||
pub files: Vec<ChangedFile>,
|
pub files: Vec<ChangedFile>,
|
||||||
@@ -273,6 +275,7 @@ pub struct DiffState {
|
|||||||
pub loading: bool,
|
pub loading: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub comparison: Option<Comparison>,
|
pub comparison: Option<Comparison>,
|
||||||
|
pub local_review: crate::review::ReviewState,
|
||||||
pub comparison_id: Option<u64>,
|
pub comparison_id: Option<u64>,
|
||||||
pub reviewed_files: BTreeSet<usize>,
|
pub reviewed_files: BTreeSet<usize>,
|
||||||
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||||
@@ -330,6 +333,7 @@ impl DiffState {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
comparison: None,
|
comparison: None,
|
||||||
|
local_review: Default::default(),
|
||||||
comparison_id: None,
|
comparison_id: None,
|
||||||
reviewed_files: BTreeSet::new(),
|
reviewed_files: BTreeSet::new(),
|
||||||
files: None,
|
files: None,
|
||||||
@@ -366,6 +370,7 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
|
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
|
||||||
let comparison = self.comparison.get_or_insert_with(|| Comparison {
|
let comparison = self.comparison.get_or_insert_with(|| Comparison {
|
||||||
|
revisions: Default::default(),
|
||||||
before: "Resolution".into(),
|
before: "Resolution".into(),
|
||||||
after: "Other version".into(),
|
after: "Other version".into(),
|
||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
@@ -1710,6 +1715,40 @@ impl DiffState {
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn review_anchor_current(&self, anchor: &crate::review::Anchor) -> bool {
|
||||||
|
use crate::review::Side;
|
||||||
|
let Some(comparison) = &self.comparison else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if self.local_review.document.root != comparison.root {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
comparison.files.iter().enumerate().any(|(index, file)| {
|
||||||
|
if !file.loaded || file.notice.is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (path, lines, eof) = match anchor.side {
|
||||||
|
Side::Before => (
|
||||||
|
file.old_path.as_deref().unwrap_or(&file.path),
|
||||||
|
&file.before,
|
||||||
|
file.before_eof,
|
||||||
|
),
|
||||||
|
Side::After => (
|
||||||
|
file.path.as_path(),
|
||||||
|
&file.after,
|
||||||
|
file.after_eof && !self.live_files.contains(&index),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
anchor.matches_lines(
|
||||||
|
&comparison.revisions[anchor.side.index()],
|
||||||
|
path,
|
||||||
|
anchor.side,
|
||||||
|
lines,
|
||||||
|
eof,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
|
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
|
||||||
let aligned = self.rows.iter().position(|aligned| match from {
|
let aligned = self.rows.iter().position(|aligned| match from {
|
||||||
Focus::Before => aligned.before == Some(row),
|
Focus::Before => aligned.before == Some(row),
|
||||||
@@ -3172,6 +3211,7 @@ mod tests {
|
|||||||
|
|
||||||
fn comparison() -> Comparison {
|
fn comparison() -> Comparison {
|
||||||
Comparison {
|
Comparison {
|
||||||
|
revisions: ["abcdef123".into(), "working:snapshot".into()],
|
||||||
before: "abcdef123".into(),
|
before: "abcdef123".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
root: PathBuf::new(),
|
root: PathBuf::new(),
|
||||||
@@ -4159,6 +4199,7 @@ mod tests {
|
|||||||
assert!(editor.apply_diff_result(
|
assert!(editor.apply_diff_result(
|
||||||
id,
|
id,
|
||||||
Ok(Outcome::Comparison(Comparison {
|
Ok(Outcome::Comparison(Comparison {
|
||||||
|
revisions: ["working:snapshot".into(), "working:snapshot".into()],
|
||||||
before: "Working".into(),
|
before: "Working".into(),
|
||||||
after: "Working".into(),
|
after: "Working".into(),
|
||||||
files: vec![],
|
files: vec![],
|
||||||
@@ -4190,6 +4231,7 @@ mod tests {
|
|||||||
Category::Commits,
|
Category::Commits,
|
||||||
));
|
));
|
||||||
state.comparison = Some(Comparison {
|
state.comparison = Some(Comparison {
|
||||||
|
revisions: ["base".into(), "working:snapshot".into()],
|
||||||
root: PathBuf::new(),
|
root: PathBuf::new(),
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "Working".into(),
|
after: "Working".into(),
|
||||||
@@ -4982,4 +5024,41 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(editor.diff().unwrap().error.is_none());
|
assert!(editor.diff().unwrap().error.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
|
||||||
|
use crate::review::{Anchor, Side};
|
||||||
|
let mut state = live_state(vec!["old".into()], vec!["new".into()], true);
|
||||||
|
let anchor = Anchor {
|
||||||
|
revision: "working:snapshot".into(),
|
||||||
|
path: "file".into(),
|
||||||
|
side: Side::After,
|
||||||
|
start_line: 1,
|
||||||
|
end_line: 1,
|
||||||
|
content: "new\n".into(),
|
||||||
|
};
|
||||||
|
assert!(state.review_anchor_current(&anchor));
|
||||||
|
state.live_files.insert(0);
|
||||||
|
assert!(!state.review_anchor_current(&anchor));
|
||||||
|
state.comparison.as_mut().unwrap().files[0]
|
||||||
|
.after
|
||||||
|
.push(String::new());
|
||||||
|
assert!(state.review_anchor_current(&anchor));
|
||||||
|
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
|
||||||
|
assert!(!state.review_anchor_current(&anchor));
|
||||||
|
state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into());
|
||||||
|
let before = Anchor {
|
||||||
|
revision: "base".into(),
|
||||||
|
path: "original".into(),
|
||||||
|
side: Side::Before,
|
||||||
|
content: "old\n".into(),
|
||||||
|
..anchor
|
||||||
|
};
|
||||||
|
assert!(state.review_anchor_current(&before));
|
||||||
|
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
||||||
|
assert!(!state.review_anchor_current(&before));
|
||||||
|
state.comparison.as_mut().unwrap().files[0].loaded = true;
|
||||||
|
state.comparison.as_mut().unwrap().revisions[0] = "other".into();
|
||||||
|
assert!(!state.review_anchor_current(&before));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+357
-2
@@ -1,3 +1,7 @@
|
|||||||
|
#[path = "editor_review.rs"]
|
||||||
|
mod local_review;
|
||||||
|
use local_review::ReviewBuffer;
|
||||||
|
|
||||||
use crate::chrome::{ChromeLine, PopupConfig, validate_name};
|
use crate::chrome::{ChromeLine, PopupConfig, validate_name};
|
||||||
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||||||
use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget, ViewMode};
|
use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget, ViewMode};
|
||||||
@@ -917,6 +921,71 @@ core_actions![
|
|||||||
&[Mode::Normal],
|
&[Mode::Normal],
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"core.diff.comment",
|
||||||
|
Action::DiffComment,
|
||||||
|
&[Mode::Normal, Mode::Visual],
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.edit",
|
||||||
|
Action::ReviewEdit,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.delete",
|
||||||
|
Action::ReviewDelete,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.next",
|
||||||
|
Action::ReviewNext,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.previous",
|
||||||
|
Action::ReviewPrevious,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.overview",
|
||||||
|
Action::ReviewOverview,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.summary",
|
||||||
|
Action::ReviewSummary,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.verdict",
|
||||||
|
Action::ReviewVerdict,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.save",
|
||||||
|
Action::ReviewSave,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"core.diff.review.open",
|
||||||
|
Action::ReviewOpen,
|
||||||
|
&[Mode::Normal],
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"core.word_wrap.toggle",
|
"core.word_wrap.toggle",
|
||||||
Action::WordWrapToggle,
|
Action::WordWrapToggle,
|
||||||
@@ -1758,6 +1827,7 @@ struct GutterChanges {
|
|||||||
struct Buffer {
|
struct Buffer {
|
||||||
id: usize,
|
id: usize,
|
||||||
diff_snapshot: Option<(u64, usize, Focus)>,
|
diff_snapshot: Option<(u64, usize, Focus)>,
|
||||||
|
review_buffer: Option<ReviewBuffer>,
|
||||||
resolver_readonly: bool,
|
resolver_readonly: bool,
|
||||||
snapshot_document_path: Option<PathBuf>,
|
snapshot_document_path: Option<PathBuf>,
|
||||||
snapshot_root: Option<PathBuf>,
|
snapshot_root: Option<PathBuf>,
|
||||||
@@ -1816,6 +1886,7 @@ impl Buffer {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
id,
|
id,
|
||||||
diff_snapshot: None,
|
diff_snapshot: None,
|
||||||
|
review_buffer: None,
|
||||||
resolver_readonly: false,
|
resolver_readonly: false,
|
||||||
snapshot_document_path: None,
|
snapshot_document_path: None,
|
||||||
snapshot_root: None,
|
snapshot_root: None,
|
||||||
@@ -3844,6 +3915,9 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn close_tab(&mut self, force: bool) {
|
fn close_tab(&mut self, force: bool) {
|
||||||
|
if !force && self.local_review_close_blocked() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.resolver_close_blocked() {
|
if self.resolver_close_blocked() {
|
||||||
self.set_message(
|
self.set_message(
|
||||||
"Resolution drafts are unsaved; write them before closing the workspace",
|
"Resolution drafts are unsaved; write them before closing the workspace",
|
||||||
@@ -3887,6 +3961,7 @@ impl Editor {
|
|||||||
self.finish_pane_interaction();
|
self.finish_pane_interaction();
|
||||||
if let Some(owner) = self.diff_owner {
|
if let Some(owner) = self.diff_owner {
|
||||||
self.with_pane(owner, |editor| {
|
self.with_pane(owner, |editor| {
|
||||||
|
editor.detach_review_buffers();
|
||||||
editor.release_diff_buffers();
|
editor.release_diff_buffers();
|
||||||
editor.diff = None;
|
editor.diff = None;
|
||||||
});
|
});
|
||||||
@@ -3954,7 +4029,11 @@ impl Editor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.finish_pane_interaction();
|
self.finish_pane_interaction();
|
||||||
|
if self.diff_owner == Some(id) && !force && self.local_review_close_blocked() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.diff_owner == Some(id) {
|
if self.diff_owner == Some(id) {
|
||||||
|
self.detach_review_buffers();
|
||||||
self.release_diff_buffers();
|
self.release_diff_buffers();
|
||||||
self.diff = None;
|
self.diff = None;
|
||||||
self.parked_diff = None;
|
self.parked_diff = None;
|
||||||
@@ -4402,10 +4481,15 @@ impl Editor {
|
|||||||
self.set_message(format!("LSP: definition failed: {error}"));
|
self.set_message(format!("LSP: definition failed: {error}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.finish_review_navigation();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_diff(&mut self, mut diff: DiffState) {
|
fn open_diff(&mut self, mut diff: DiffState) {
|
||||||
|
if self.local_review_close_blocked() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.detach_review_buffers();
|
||||||
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
|
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
|
||||||
self.with_pane(owner, |editor| {
|
self.with_pane(owner, |editor| {
|
||||||
editor.release_diff_buffers();
|
editor.release_diff_buffers();
|
||||||
@@ -4588,6 +4672,11 @@ impl Editor {
|
|||||||
.diff_bound
|
.diff_bound
|
||||||
.is_some_and(|bound| bound.3 != self.active.id)
|
.is_some_and(|bound| bound.3 != self.active.id)
|
||||||
{
|
{
|
||||||
|
if self.current_review_dirty() {
|
||||||
|
self.bind_diff_buffer();
|
||||||
|
self.set_message("Save the local review before replacing its comparison");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(role) = self.active.diff_snapshot {
|
if let Some(role) = self.active.diff_snapshot {
|
||||||
if self
|
if self
|
||||||
.diff
|
.diff
|
||||||
@@ -5131,6 +5220,16 @@ impl Editor {
|
|||||||
| Action::DiffViewToggle
|
| Action::DiffViewToggle
|
||||||
| Action::DiffOrientationToggle
|
| Action::DiffOrientationToggle
|
||||||
| Action::DiffReviewToggle
|
| Action::DiffReviewToggle
|
||||||
|
| Action::DiffComment
|
||||||
|
| Action::ReviewEdit
|
||||||
|
| Action::ReviewDelete
|
||||||
|
| Action::ReviewNext
|
||||||
|
| Action::ReviewPrevious
|
||||||
|
| Action::ReviewOverview
|
||||||
|
| Action::ReviewSummary
|
||||||
|
| Action::ReviewVerdict
|
||||||
|
| Action::ReviewSave
|
||||||
|
| Action::ReviewOpen
|
||||||
| Action::WordWrapToggle
|
| Action::WordWrapToggle
|
||||||
| Action::GitBlameToggle
|
| Action::GitBlameToggle
|
||||||
| Action::Quit
|
| Action::Quit
|
||||||
@@ -7701,6 +7800,9 @@ impl Editor {
|
|||||||
Input::Ctrl(ch) => control_key(ch),
|
Input::Ctrl(ch) => control_key(ch),
|
||||||
input => input,
|
input => input,
|
||||||
};
|
};
|
||||||
|
if self.handle_review_overview(&input) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if self.handle_resolver_history_focus(&input) {
|
if self.handle_resolver_history_focus(&input) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -8178,12 +8280,13 @@ impl Editor {
|
|||||||
&& !(matches!(
|
&& !(matches!(
|
||||||
action,
|
action,
|
||||||
Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit
|
Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit
|
||||||
) && self.resolvers.values().any(|resolver| {
|
) && (self.active.review_buffer.is_some()
|
||||||
|
|| self.resolvers.values().any(|resolver| {
|
||||||
resolver
|
resolver
|
||||||
.files
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
.any(|file| file.draft_buffer == self.active.id)
|
.any(|file| file.draft_buffer == self.active.id)
|
||||||
}))
|
})))
|
||||||
{
|
{
|
||||||
self.set_message("Input pane is read-only");
|
self.set_message("Input pane is read-only");
|
||||||
self.count = None;
|
self.count = None;
|
||||||
@@ -8303,6 +8406,16 @@ impl Editor {
|
|||||||
| Action::DiffViewToggle
|
| Action::DiffViewToggle
|
||||||
| Action::DiffOrientationToggle
|
| Action::DiffOrientationToggle
|
||||||
| Action::DiffReviewToggle
|
| Action::DiffReviewToggle
|
||||||
|
| Action::DiffComment
|
||||||
|
| Action::ReviewEdit
|
||||||
|
| Action::ReviewDelete
|
||||||
|
| Action::ReviewNext
|
||||||
|
| Action::ReviewPrevious
|
||||||
|
| Action::ReviewOverview
|
||||||
|
| Action::ReviewSummary
|
||||||
|
| Action::ReviewVerdict
|
||||||
|
| Action::ReviewSave
|
||||||
|
| Action::ReviewOpen
|
||||||
| Action::WordWrapToggle
|
| Action::WordWrapToggle
|
||||||
| Action::GitBlameToggle
|
| Action::GitBlameToggle
|
||||||
| Action::LspDefinition
|
| Action::LspDefinition
|
||||||
@@ -8973,8 +9086,17 @@ impl Editor {
|
|||||||
.filter_map(|tab| tab.diff_owner),
|
.filter_map(|tab| tab.diff_owner),
|
||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
if !force
|
||||||
|
&& owners
|
||||||
|
.iter()
|
||||||
|
.any(|owner| self.with_pane(*owner, |editor| editor.current_review_dirty()))
|
||||||
|
{
|
||||||
|
self.set_message("Save the local review before deleting its comparison buffer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
for owner in owners {
|
for owner in owners {
|
||||||
self.with_pane(owner, |editor| {
|
self.with_pane(owner, |editor| {
|
||||||
|
editor.detach_review_buffers();
|
||||||
editor.release_diff_buffers();
|
editor.release_diff_buffers();
|
||||||
editor.diff = None;
|
editor.diff = None;
|
||||||
editor.parked_diff = None;
|
editor.parked_diff = None;
|
||||||
@@ -9078,6 +9200,7 @@ impl Editor {
|
|||||||
fn any_dirty(&self) -> bool {
|
fn any_dirty(&self) -> bool {
|
||||||
self.active.dirty()
|
self.active.dirty()
|
||||||
|| self.parked.values().any(Buffer::dirty)
|
|| self.parked.values().any(Buffer::dirty)
|
||||||
|
|| self.local_reviews_dirty()
|
||||||
|| self.resolvers.values().any(|resolver| {
|
|| self.resolvers.values().any(|resolver| {
|
||||||
resolver.pending
|
resolver.pending
|
||||||
|| resolver
|
|| resolver
|
||||||
@@ -9088,6 +9211,10 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restart(&mut self, force: bool) {
|
fn restart(&mut self, force: bool) {
|
||||||
|
if !force && self.local_reviews_dirty() {
|
||||||
|
self.set_message("Unsaved local review; use :diff.review.save <path>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if force || !self.any_dirty() {
|
if force || !self.any_dirty() {
|
||||||
self.restart = true;
|
self.restart = true;
|
||||||
return;
|
return;
|
||||||
@@ -9184,6 +9311,25 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
|
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
|
||||||
|
if matches!(
|
||||||
|
entry.action,
|
||||||
|
Action::DiffComment
|
||||||
|
| Action::ReviewEdit
|
||||||
|
| Action::ReviewDelete
|
||||||
|
| Action::ReviewNext
|
||||||
|
| Action::ReviewPrevious
|
||||||
|
| Action::ReviewOverview
|
||||||
|
| Action::ReviewSummary
|
||||||
|
| Action::ReviewVerdict
|
||||||
|
| Action::ReviewSave
|
||||||
|
| Action::ReviewOpen
|
||||||
|
) {
|
||||||
|
self.finish_diff_prompt();
|
||||||
|
if let Err(error) = self.local_review_command(entry.action, args) {
|
||||||
|
self.set_message(error);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
entry.action,
|
entry.action,
|
||||||
Action::Resolve
|
Action::Resolve
|
||||||
@@ -9210,6 +9356,14 @@ impl Editor {
|
|||||||
self.prepare_command();
|
self.prepare_command();
|
||||||
self.finish_command_mode();
|
self.finish_command_mode();
|
||||||
let quit = matches!(entry.action, Action::SaveAndQuit | Action::ForceSaveAndQuit);
|
let quit = matches!(entry.action, Action::SaveAndQuit | Action::ForceSaveAndQuit);
|
||||||
|
if self.active.review_buffer.is_some() {
|
||||||
|
match self.save_local_review((!args.is_empty()).then(|| Path::new(args))) {
|
||||||
|
Ok(()) if quit && !self.any_dirty() => self.quit = true,
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) => self.set_message(error),
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if self.queue_resolver_save((!args.is_empty()).then(|| Path::new(args)), quit) {
|
if self.queue_resolver_save((!args.is_empty()).then(|| Path::new(args)), quit) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -9418,6 +9572,10 @@ impl Editor {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.diff.as_ref()?;
|
self.diff.as_ref()?;
|
||||||
|
if self.local_review_close_blocked() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.detach_review_buffers();
|
||||||
self.release_diff_buffers();
|
self.release_diff_buffers();
|
||||||
self.diff = None;
|
self.diff = None;
|
||||||
self.diff_owner = None;
|
self.diff_owner = None;
|
||||||
@@ -9695,6 +9853,15 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save(&mut self, path: Option<PathBuf>, force: bool) -> bool {
|
fn save(&mut self, path: Option<PathBuf>, force: bool) -> bool {
|
||||||
|
if self.active.review_buffer.is_some() {
|
||||||
|
return match self.save_local_review(path.as_deref()) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(error) => {
|
||||||
|
self.set_message(error);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
if self.queue_resolver_save(path.as_deref(), false) {
|
if self.queue_resolver_save(path.as_deref(), false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -14435,6 +14602,7 @@ mod tests {
|
|||||||
comparison_request,
|
comparison_request,
|
||||||
Ok(Outcome::Comparison(Comparison {
|
Ok(Outcome::Comparison(Comparison {
|
||||||
root: editor.project_root().to_owned(),
|
root: editor.project_root().to_owned(),
|
||||||
|
revisions: ["base".into(), "working-snapshot".into()],
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "working".into(),
|
after: "working".into(),
|
||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
@@ -15623,6 +15791,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
Ok(Outcome::Comparison(Comparison {
|
Ok(Outcome::Comparison(Comparison {
|
||||||
root: root.canonicalize().unwrap(),
|
root: root.canonicalize().unwrap(),
|
||||||
|
revisions: ["base".into(), "working-snapshot".into()],
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "Working".into(),
|
after: "Working".into(),
|
||||||
editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(),
|
editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(),
|
||||||
@@ -15661,6 +15830,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
Ok(Outcome::Comparison(Comparison {
|
Ok(Outcome::Comparison(Comparison {
|
||||||
root: root.to_owned(),
|
root: root.to_owned(),
|
||||||
|
revisions: ["base".into(), "head".into()],
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "head".into(),
|
after: "head".into(),
|
||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
@@ -15683,6 +15853,189 @@ mod tests {
|
|||||||
assert!(editor.diff_interacting());
|
assert!(editor.diff_interacting());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_review_native_composition_roundtrip_and_draft_lifecycle() {
|
||||||
|
use crate::review::{Review, Side, Verdict};
|
||||||
|
let directory = Directory::new();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let path = root.join("feedback.json");
|
||||||
|
let mut editor = editor();
|
||||||
|
historical_diff(
|
||||||
|
&mut editor,
|
||||||
|
&root,
|
||||||
|
vec!["before".into(), "removed".into()],
|
||||||
|
vec!["after".into(), "added".into()],
|
||||||
|
);
|
||||||
|
let owner = editor.pane_id();
|
||||||
|
let source = editor.context().text;
|
||||||
|
editor.execute("diff.comment");
|
||||||
|
assert_ne!(editor.pane_id(), owner);
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.review_panel_title()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("Comment #1")
|
||||||
|
);
|
||||||
|
type_text(&mut editor, "iExplain the removed branch");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("diff.review.summary");
|
||||||
|
type_text(&mut editor, "iPlease simplify the branch");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("diff.review.verdict changes_requested");
|
||||||
|
assert!(editor.context().text.contains("Changes requested"));
|
||||||
|
editor.execute(&format!("diff.review.save {}", path.display()));
|
||||||
|
let review = Review::load(&path, &root).unwrap();
|
||||||
|
assert_eq!(review.comments[0].body, "Explain the removed branch");
|
||||||
|
assert_eq!(review.comments[0].anchor.side, Side::Before);
|
||||||
|
assert_eq!(review.comments[0].anchor.content, source);
|
||||||
|
assert_eq!(review.summary, "Please simplify the branch");
|
||||||
|
assert_eq!(review.verdict, Some(Verdict::ChangesRequested));
|
||||||
|
assert!(!editor.local_reviews_dirty());
|
||||||
|
editor.execute("diff.review.edit 1");
|
||||||
|
// Native undo applies to comment text, never to the reviewed source.
|
||||||
|
type_text(&mut editor, "A!");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, "Explain the removed branch");
|
||||||
|
editor.execute("w");
|
||||||
|
editor.execute("diff.close");
|
||||||
|
assert!(editor.workspace_diff().is_none());
|
||||||
|
historical_diff(
|
||||||
|
&mut editor,
|
||||||
|
&root,
|
||||||
|
vec!["before".into(), "removed".into()],
|
||||||
|
vec!["after".into(), "added".into()],
|
||||||
|
);
|
||||||
|
editor.execute(&format!("diff.review.open {}", path.display()));
|
||||||
|
assert!(editor.context().text.contains("Explain the removed branch"));
|
||||||
|
editor.execute("diff.review.next");
|
||||||
|
assert_eq!(editor.context().text, "Explain the removed branch");
|
||||||
|
editor.execute("diff.review.delete");
|
||||||
|
assert!(editor.context().text.starts_with("# Local review"));
|
||||||
|
editor.execute("w");
|
||||||
|
assert!(Review::load(&path, &root).unwrap().comments.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.workspace_diff()
|
||||||
|
.unwrap()
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.files[0]
|
||||||
|
.before,
|
||||||
|
["before", "removed"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_review_visual_palette_anchors_both_sides_and_preserves_stale_ranges() {
|
||||||
|
use crate::review::Side;
|
||||||
|
for focus in [Focus::Before, Focus::After] {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let mut editor = editor();
|
||||||
|
historical_diff(
|
||||||
|
&mut editor,
|
||||||
|
&root,
|
||||||
|
vec!["old first".into(), "old middle".into(), "old last".into()],
|
||||||
|
vec!["new first".into(), "new middle".into(), "new last".into()],
|
||||||
|
);
|
||||||
|
editor.diff.as_mut().unwrap().focus = focus;
|
||||||
|
editor.bind_diff_buffer();
|
||||||
|
type_text(&mut editor, "Vj");
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
type_text(&mut editor, "diff.comment");
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
let diff = editor.workspace_diff().unwrap();
|
||||||
|
let anchor = diff.local_review.document.comments[0].anchor.clone();
|
||||||
|
assert_eq!((anchor.start_line, anchor.end_line), (1, 2));
|
||||||
|
assert_eq!(
|
||||||
|
anchor.side,
|
||||||
|
if focus == Focus::Before {
|
||||||
|
Side::Before
|
||||||
|
} else {
|
||||||
|
Side::After
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(diff.review_anchor_current(&anchor));
|
||||||
|
let source_pane = editor.diff_owner.unwrap();
|
||||||
|
editor.select_pane(source_pane);
|
||||||
|
editor
|
||||||
|
.diff
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.comparison
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.revisions[anchor.side.index()] = "changed-snapshot".into();
|
||||||
|
editor.execute("diff.review.overview");
|
||||||
|
assert!(editor.context().text.contains("stale"));
|
||||||
|
editor.execute("diff.review.edit 1");
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.workspace_diff()
|
||||||
|
.unwrap()
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.comments[0]
|
||||||
|
.anchor,
|
||||||
|
anchor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_review_guards_metadata_and_hidden_native_drafts_against_loss() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let mut editor = editor();
|
||||||
|
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
editor.execute("diff.comment");
|
||||||
|
type_text(&mut editor, "iUnsaved feedback");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.execute("diff.review.overview");
|
||||||
|
let panel = editor.pane_id();
|
||||||
|
let source_buffer = editor.diff_return_buffer.unwrap();
|
||||||
|
editor.delete_buffer(source_buffer, false);
|
||||||
|
assert!(editor.has_buffer(source_buffer));
|
||||||
|
assert!(editor.workspace_diff().is_some());
|
||||||
|
editor.select_pane(editor.diff_owner.unwrap());
|
||||||
|
fs::write(root.join("other.txt"), "other document").unwrap();
|
||||||
|
editor.execute(&format!("edit {}", root.join("other.txt").display()));
|
||||||
|
assert!(editor.workspace_diff().is_some());
|
||||||
|
assert_eq!(editor.context().text, "old");
|
||||||
|
editor.select_pane(panel);
|
||||||
|
editor.execute("diff.close");
|
||||||
|
assert!(editor.workspace_diff().is_some());
|
||||||
|
editor.execute("tab.close");
|
||||||
|
assert!(!editor.should_quit());
|
||||||
|
editor.execute("restart");
|
||||||
|
assert!(!editor.should_restart());
|
||||||
|
editor.execute("q");
|
||||||
|
assert!(!editor.should_quit());
|
||||||
|
let invalid = root.join("missing/review.json");
|
||||||
|
editor.execute(&format!("diff.review.save {}", invalid.display()));
|
||||||
|
assert!(editor.local_reviews_dirty());
|
||||||
|
assert_eq!(
|
||||||
|
editor
|
||||||
|
.workspace_diff()
|
||||||
|
.unwrap()
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.comments[0]
|
||||||
|
.body,
|
||||||
|
"Unsaved feedback"
|
||||||
|
);
|
||||||
|
let path = root.join("review.json");
|
||||||
|
editor.execute(&format!("diff.review.save {}", path.display()));
|
||||||
|
assert!(!editor.local_reviews_dirty());
|
||||||
|
fs::write(&path, "external change").unwrap();
|
||||||
|
editor.execute("diff.review.verdict looks_good");
|
||||||
|
editor.execute("w");
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "external change");
|
||||||
|
assert!(editor.local_reviews_dirty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn working_refresh_routes_hidden_owners_and_keeps_obsolete_shared_snapshots_readonly() {
|
fn working_refresh_routes_hidden_owners_and_keeps_obsolete_shared_snapshots_readonly() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -15807,6 +16160,7 @@ mod tests {
|
|||||||
refresh,
|
refresh,
|
||||||
Ok(Outcome::BookmarkRefresh(Comparison {
|
Ok(Outcome::BookmarkRefresh(Comparison {
|
||||||
root: directory.0.clone(),
|
root: directory.0.clone(),
|
||||||
|
revisions: ["base".into(), "topic-snapshot".into()],
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "topic".into(),
|
after: "topic".into(),
|
||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
@@ -15927,6 +16281,7 @@ mod tests {
|
|||||||
target.loaded = false;
|
target.loaded = false;
|
||||||
let replacement = crate::diff::Comparison {
|
let replacement = crate::diff::Comparison {
|
||||||
root: directory.0.clone(),
|
root: directory.0.clone(),
|
||||||
|
revisions: ["base".into(), "ahead-snapshot".into()],
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "ahead".into(),
|
after: "ahead".into(),
|
||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::review::{Anchor, Comment, Review, Side, Verdict};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
|
enum ReviewText {
|
||||||
|
Comment(u64),
|
||||||
|
Summary,
|
||||||
|
Overview,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct ReviewBuffer {
|
||||||
|
owner: usize,
|
||||||
|
kind: ReviewText,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor {
|
||||||
|
pub fn review_panel_title(&self) -> Option<&str> {
|
||||||
|
self.active
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.map(|buffer| buffer.title.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_diff_mut(&mut self) -> Option<&mut DiffState> {
|
||||||
|
self.diff.as_mut().or(self.parked_diff.as_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn local_reviews_dirty(&self) -> bool {
|
||||||
|
self.workspace_diff()
|
||||||
|
.is_some_and(|diff| diff.local_review.dirty)
|
||||||
|
|| self
|
||||||
|
.tabs
|
||||||
|
.values()
|
||||||
|
.any(|tab| tab.diff().is_some_and(|diff| diff.local_review.dirty))
|
||||||
|
|| std::iter::once(&self.active)
|
||||||
|
.chain(self.parked.values())
|
||||||
|
.any(|buffer| buffer.review_buffer.is_some() && buffer.dirty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn current_review_dirty(&self) -> bool {
|
||||||
|
self.workspace_diff()
|
||||||
|
.is_some_and(|diff| diff.local_review.dirty)
|
||||||
|
|| std::iter::once(&self.active)
|
||||||
|
.chain(self.parked.values())
|
||||||
|
.any(|buffer| {
|
||||||
|
buffer
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||||
|
&& buffer.dirty()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn local_review_close_blocked(&mut self) -> bool {
|
||||||
|
if self.current_review_dirty() {
|
||||||
|
self.set_message("Unsaved local review; use :diff.review.save <path> before closing");
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn detach_review_buffers(&mut self) {
|
||||||
|
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||||
|
if buffer
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||||
|
{
|
||||||
|
buffer.review_buffer = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_review_text(&mut self) {
|
||||||
|
let owner = self.tab_id;
|
||||||
|
let updates: Vec<_> = std::iter::once(&self.active)
|
||||||
|
.chain(self.parked.values())
|
||||||
|
.filter_map(|buffer| {
|
||||||
|
let binding = buffer.review_buffer.as_ref()?;
|
||||||
|
(binding.owner == owner && binding.kind != ReviewText::Overview)
|
||||||
|
.then(|| (binding.kind, buffer.lines.join("\n")))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let Some(diff) = self.review_diff_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (kind, text) in updates {
|
||||||
|
let destination = match kind {
|
||||||
|
ReviewText::Comment(id) => diff
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.comments
|
||||||
|
.iter_mut()
|
||||||
|
.find(|comment| comment.id == id)
|
||||||
|
.map(|comment| &mut comment.body),
|
||||||
|
ReviewText::Summary => Some(&mut diff.local_review.document.summary),
|
||||||
|
ReviewText::Overview => None,
|
||||||
|
};
|
||||||
|
if let Some(destination) = destination
|
||||||
|
&& *destination != text
|
||||||
|
{
|
||||||
|
*destination = text;
|
||||||
|
diff.local_review.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn save_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
|
||||||
|
self.sync_review_text();
|
||||||
|
let state = &mut self
|
||||||
|
.review_diff_mut()
|
||||||
|
.ok_or("No comparison is open")?
|
||||||
|
.local_review;
|
||||||
|
let path = path
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.or_else(|| state.path.clone())
|
||||||
|
.ok_or("Use :diff.review.save <path> to choose a local JSON review file")?;
|
||||||
|
state.document.save(&path)?;
|
||||||
|
state.path = Some(path);
|
||||||
|
state.dirty = false;
|
||||||
|
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||||
|
if buffer
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||||
|
{
|
||||||
|
buffer.saved_lines.clone_from(&buffer.lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.set_message("Local review saved");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn local_review_command(
|
||||||
|
&mut self,
|
||||||
|
action: Action,
|
||||||
|
args: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.resolver().is_some() {
|
||||||
|
return Err("Local reviews require an ordinary diff comparison".into());
|
||||||
|
}
|
||||||
|
let diff = self
|
||||||
|
.workspace_diff()
|
||||||
|
.filter(|diff| diff.phase == Phase::View)
|
||||||
|
.ok_or("No comparison is open")?;
|
||||||
|
let root = diff
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Comparison is still loading")?
|
||||||
|
.root
|
||||||
|
.clone();
|
||||||
|
if !matches!(
|
||||||
|
action,
|
||||||
|
Action::ReviewEdit
|
||||||
|
| Action::ReviewDelete
|
||||||
|
| Action::ReviewVerdict
|
||||||
|
| Action::ReviewSave
|
||||||
|
| Action::ReviewOpen
|
||||||
|
) && !args.is_empty()
|
||||||
|
{
|
||||||
|
return Err("This review command takes no arguments".into());
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.workspace_diff()
|
||||||
|
.unwrap()
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.root
|
||||||
|
.as_os_str()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
|
||||||
|
}
|
||||||
|
if action == Action::ReviewOpen {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Err("Usage: diff.review.open <path>".into());
|
||||||
|
}
|
||||||
|
if self.current_review_dirty() {
|
||||||
|
return Err("Save the current local review before opening another".into());
|
||||||
|
}
|
||||||
|
let document = Review::load(Path::new(args), &root)?;
|
||||||
|
self.detach_review_buffers();
|
||||||
|
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||||
|
state.document = document;
|
||||||
|
state.path = Some(PathBuf::from(args));
|
||||||
|
state.selected = None;
|
||||||
|
state.pending_navigation = None;
|
||||||
|
self.open_review_overview()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.sync_review_text();
|
||||||
|
match action {
|
||||||
|
Action::DiffComment => self.add_review_comment()?,
|
||||||
|
Action::ReviewEdit => {
|
||||||
|
let id = self.review_comment_id(args)?;
|
||||||
|
self.show_review_comment(id)?;
|
||||||
|
}
|
||||||
|
Action::ReviewDelete => {
|
||||||
|
let id = self.review_comment_id(args)?;
|
||||||
|
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||||
|
state.document.comments.retain(|comment| comment.id != id);
|
||||||
|
state.selected = None;
|
||||||
|
state.pending_navigation = None;
|
||||||
|
state.dirty = true;
|
||||||
|
// The deleted draft cannot later overwrite another comment with the same ID.
|
||||||
|
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||||
|
if buffer.review_buffer.as_ref().is_some_and(|binding| {
|
||||||
|
binding.owner == self.tab_id && binding.kind == ReviewText::Comment(id)
|
||||||
|
}) {
|
||||||
|
buffer.review_buffer = None;
|
||||||
|
buffer.saved_lines.clone_from(&buffer.lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.open_review_overview()?;
|
||||||
|
}
|
||||||
|
Action::ReviewNext | Action::ReviewPrevious => {
|
||||||
|
let state = &self.workspace_diff().unwrap().local_review;
|
||||||
|
let comments = &state.document.comments;
|
||||||
|
if comments.is_empty() {
|
||||||
|
return Err("No local review comments".into());
|
||||||
|
}
|
||||||
|
let current = state
|
||||||
|
.selected
|
||||||
|
.and_then(|id| comments.iter().position(|comment| comment.id == id));
|
||||||
|
let index = match (current, action == Action::ReviewNext) {
|
||||||
|
(Some(index), true) => (index + 1) % comments.len(),
|
||||||
|
(Some(index), false) => (index + comments.len() - 1) % comments.len(),
|
||||||
|
(None, true) => 0,
|
||||||
|
(None, false) => comments.len() - 1,
|
||||||
|
};
|
||||||
|
self.show_review_comment(comments[index].id)?;
|
||||||
|
}
|
||||||
|
Action::ReviewOverview => self.open_review_overview()?,
|
||||||
|
Action::ReviewSummary => {
|
||||||
|
let text = self
|
||||||
|
.workspace_diff()
|
||||||
|
.unwrap()
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.summary
|
||||||
|
.clone();
|
||||||
|
self.open_review_buffer(
|
||||||
|
ReviewText::Summary,
|
||||||
|
&text,
|
||||||
|
"Review summary · :w saves review".into(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Action::ReviewVerdict => {
|
||||||
|
let verdict = match args {
|
||||||
|
"looks_good" => Some(Verdict::LooksGood),
|
||||||
|
"changes_requested" => Some(Verdict::ChangesRequested),
|
||||||
|
"none" => None,
|
||||||
|
_ => {
|
||||||
|
return Err(
|
||||||
|
"Usage: diff.review.verdict <looks_good|changes_requested|none>".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||||
|
if state.document.verdict != verdict {
|
||||||
|
state.document.verdict = verdict;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
self.open_review_overview()?;
|
||||||
|
}
|
||||||
|
Action::ReviewSave => {
|
||||||
|
self.save_local_review((!args.is_empty()).then(|| Path::new(args)))?
|
||||||
|
}
|
||||||
|
_ => unreachable!("local review action"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_comment_id(&self, args: &str) -> Result<u64, String> {
|
||||||
|
let state = &self
|
||||||
|
.workspace_diff()
|
||||||
|
.ok_or("No comparison is open")?
|
||||||
|
.local_review;
|
||||||
|
let under_cursor = self
|
||||||
|
.active
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.filter(|binding| binding.kind == ReviewText::Overview)
|
||||||
|
.and_then(|_| {
|
||||||
|
self.active.lines[..=self.cursor().row]
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|line| {
|
||||||
|
line.strip_prefix("## Comment #")?
|
||||||
|
.split_whitespace()
|
||||||
|
.next()?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let id = if args.is_empty() {
|
||||||
|
under_cursor
|
||||||
|
.or(state.selected)
|
||||||
|
.ok_or("Select a comment or supply its ID")?
|
||||||
|
} else {
|
||||||
|
args.parse()
|
||||||
|
.map_err(|_| "Comment ID must be a positive integer")?
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.document
|
||||||
|
.comments
|
||||||
|
.iter()
|
||||||
|
.any(|comment| comment.id == id)
|
||||||
|
.then_some(id)
|
||||||
|
.ok_or_else(|| format!("No local comment #{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_review_comment(&mut self) -> Result<(), String> {
|
||||||
|
let owner = self.diff_owner.ok_or("No comparison is open")?;
|
||||||
|
if self.pane_id != owner {
|
||||||
|
self.select_pane(owner);
|
||||||
|
}
|
||||||
|
self.bind_diff_buffer();
|
||||||
|
let focus = self
|
||||||
|
.diff_buffer_focus()
|
||||||
|
.filter(|_| self.diff_interacting())
|
||||||
|
.ok_or("Focus a diff text pane to add a comment")?;
|
||||||
|
if self.selection_count() != 1 {
|
||||||
|
return Err("Use one caret or one line range for a review comment".into());
|
||||||
|
}
|
||||||
|
let selection = self.selections().into_iter().next();
|
||||||
|
let (first, last) = selection.map_or((self.cursor().row, self.cursor().row), |selection| {
|
||||||
|
let last = selection.end.row.saturating_sub(usize::from(
|
||||||
|
selection.end.column == 0 && selection.end.row > selection.start.row,
|
||||||
|
));
|
||||||
|
(selection.start.row, last)
|
||||||
|
});
|
||||||
|
let diff = self.diff.as_ref().unwrap();
|
||||||
|
let comparison = diff.comparison.as_ref().unwrap();
|
||||||
|
let file = &comparison.files[diff.file];
|
||||||
|
let side = if focus == Focus::Before {
|
||||||
|
Side::Before
|
||||||
|
} else {
|
||||||
|
Side::After
|
||||||
|
};
|
||||||
|
let anchor = Anchor {
|
||||||
|
revision: comparison.revisions[side.index()].clone(),
|
||||||
|
path: if side == Side::Before {
|
||||||
|
file.old_path.as_ref().unwrap_or(&file.path)
|
||||||
|
} else {
|
||||||
|
&file.path
|
||||||
|
}
|
||||||
|
.clone(),
|
||||||
|
side,
|
||||||
|
start_line: first + 1,
|
||||||
|
end_line: last + 1,
|
||||||
|
content: self.active.lines.join("\n"),
|
||||||
|
};
|
||||||
|
let id = diff
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.comments
|
||||||
|
.iter()
|
||||||
|
.map(|comment| comment.id)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or("Review comment IDs exhausted")?;
|
||||||
|
self.prepare_command();
|
||||||
|
self.finish_command_mode();
|
||||||
|
let state = &mut self.diff.as_mut().unwrap().local_review;
|
||||||
|
state.document.comments.push(Comment {
|
||||||
|
id,
|
||||||
|
anchor,
|
||||||
|
body: String::new(),
|
||||||
|
});
|
||||||
|
state.dirty = true;
|
||||||
|
self.show_review_comment(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_review_comment(&mut self, id: u64) -> Result<(), String> {
|
||||||
|
let diff = self.workspace_diff().unwrap();
|
||||||
|
let comment = diff
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.comments
|
||||||
|
.iter()
|
||||||
|
.find(|comment| comment.id == id)
|
||||||
|
.ok_or("Comment no longer exists")?;
|
||||||
|
let text = comment.body.clone();
|
||||||
|
let anchor = &comment.anchor;
|
||||||
|
let title = format!(
|
||||||
|
"Comment #{id} · {}:{}–{} · {:?} · :w saves review",
|
||||||
|
anchor.path.display(),
|
||||||
|
anchor.start_line,
|
||||||
|
anchor.end_line,
|
||||||
|
anchor.side
|
||||||
|
);
|
||||||
|
let target = diff.comparison.as_ref().and_then(|comparison| {
|
||||||
|
(comparison.revisions[anchor.side.index()] == anchor.revision)
|
||||||
|
.then(|| {
|
||||||
|
comparison.files.iter().position(|file| {
|
||||||
|
let path = if anchor.side == Side::Before {
|
||||||
|
file.old_path.as_ref().unwrap_or(&file.path)
|
||||||
|
} else {
|
||||||
|
&file.path
|
||||||
|
};
|
||||||
|
path == &anchor.path
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
});
|
||||||
|
let owner = self.diff_owner.unwrap();
|
||||||
|
self.select_pane(owner);
|
||||||
|
let diff = self.diff.as_mut().unwrap();
|
||||||
|
diff.local_review.selected = Some(id);
|
||||||
|
if let Some(index) = target {
|
||||||
|
diff.select_file_index(index);
|
||||||
|
diff.local_review.pending_navigation = Some(id);
|
||||||
|
}
|
||||||
|
self.finish_review_navigation();
|
||||||
|
self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
|
||||||
|
if target.is_none() {
|
||||||
|
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn finish_review_navigation(&mut self) {
|
||||||
|
let Some(diff) = self.diff.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(id) = diff.local_review.pending_navigation else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !diff.selected_file_loaded() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
diff.local_review.pending_navigation = None;
|
||||||
|
let Some(comment) = diff
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.comments
|
||||||
|
.iter()
|
||||||
|
.find(|comment| comment.id == id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let selected_path = diff
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|comparison| comparison.files.get(diff.file))
|
||||||
|
.map(|file| {
|
||||||
|
if comment.anchor.side == Side::Before {
|
||||||
|
file.old_path.as_ref().unwrap_or(&file.path)
|
||||||
|
} else {
|
||||||
|
&file.path
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if selected_path != Some(&comment.anchor.path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !diff.review_anchor_current(&comment.anchor) {
|
||||||
|
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let row = comment.anchor.start_line - 1;
|
||||||
|
let focus = if comment.anchor.side == Side::Before {
|
||||||
|
Focus::Before
|
||||||
|
} else {
|
||||||
|
Focus::After
|
||||||
|
};
|
||||||
|
diff.focus = focus;
|
||||||
|
diff.reveal_source_row(focus, row);
|
||||||
|
self.bind_diff_buffer();
|
||||||
|
self.place_navigation(TextPosition { row, column: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_review_overview(&mut self) -> Result<(), String> {
|
||||||
|
let diff = self.workspace_diff().unwrap();
|
||||||
|
let state = &diff.local_review;
|
||||||
|
let verdict = match state.document.verdict {
|
||||||
|
Some(Verdict::LooksGood) => "Looks good",
|
||||||
|
Some(Verdict::ChangesRequested) => "Changes requested",
|
||||||
|
None => "Not set",
|
||||||
|
};
|
||||||
|
let mut text = format!(
|
||||||
|
"# Local review\n\nVerdict: {verdict}\nFile: {}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:diff.review.summary · :diff.review.verdict · :diff.review.save\n",
|
||||||
|
state
|
||||||
|
.path
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| "not saved".into(), |path| path.display().to_string()),
|
||||||
|
state.document.summary
|
||||||
|
);
|
||||||
|
for comment in &state.document.comments {
|
||||||
|
let anchor = &comment.anchor;
|
||||||
|
let status = if diff.review_anchor_current(anchor) {
|
||||||
|
"current"
|
||||||
|
} else if diff.comparison.as_ref().is_some_and(|comparison| {
|
||||||
|
comparison.revisions[anchor.side.index()] == anchor.revision
|
||||||
|
&& comparison.files.iter().any(|file| {
|
||||||
|
!file.loaded
|
||||||
|
&& if anchor.side == Side::Before {
|
||||||
|
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
|
||||||
|
} else {
|
||||||
|
file.path == anchor.path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
"not checked (file not loaded)"
|
||||||
|
} else {
|
||||||
|
"stale"
|
||||||
|
};
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
write!(
|
||||||
|
text,
|
||||||
|
"\n## Comment #{} · {status}\n{}:{}–{} · {:?}\nRevision: {}\n{}\n",
|
||||||
|
comment.id,
|
||||||
|
anchor.path.display(),
|
||||||
|
anchor.start_line,
|
||||||
|
anchor.end_line,
|
||||||
|
anchor.side,
|
||||||
|
anchor.revision,
|
||||||
|
comment.body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
self.open_review_buffer(
|
||||||
|
ReviewText::Overview,
|
||||||
|
&text,
|
||||||
|
"Local review overview · Enter edits · :diff.review.next".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_review_buffer(
|
||||||
|
&mut self,
|
||||||
|
kind: ReviewText,
|
||||||
|
text: &str,
|
||||||
|
title: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.prepare_command();
|
||||||
|
self.finish_command_mode();
|
||||||
|
let existing = std::iter::once(&self.active)
|
||||||
|
.chain(self.parked.values())
|
||||||
|
.find(|buffer| {
|
||||||
|
buffer
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.owner == self.tab_id && binding.kind == kind)
|
||||||
|
})
|
||||||
|
.map(|buffer| buffer.id);
|
||||||
|
let panel = self.panes.iter().find_map(|(id, pane)| {
|
||||||
|
(self.pane_layout.contains(*id)
|
||||||
|
&& self.buffer(pane.buffer_id).is_some_and(|buffer| {
|
||||||
|
buffer
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||||
|
}))
|
||||||
|
.then_some(*id)
|
||||||
|
});
|
||||||
|
if let Some(panel) = panel {
|
||||||
|
self.select_pane(panel);
|
||||||
|
} else {
|
||||||
|
self.select_pane(self.diff_owner.unwrap());
|
||||||
|
self.split_pane(SplitAxis::Vertical);
|
||||||
|
}
|
||||||
|
if let Some(id) = existing {
|
||||||
|
self.switch_buffer(id);
|
||||||
|
if kind == ReviewText::Overview {
|
||||||
|
self.active.lines = split_lines(text);
|
||||||
|
self.active.saved_lines.clone_from(&self.active.lines);
|
||||||
|
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||||
|
self.normalize_targets();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut buffer = Buffer::from_text(self.next_buffer_id, None, None, text, None)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
buffer.review_buffer = Some(ReviewBuffer {
|
||||||
|
owner: self.tab_id,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
buffer.resolver_readonly = kind == ReviewText::Overview;
|
||||||
|
self.add_buffer(buffer);
|
||||||
|
}
|
||||||
|
self.set_message("Local review · :w saves JSON; first choose :diff.review.save <path>");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_review_overview(&mut self, input: &Input) -> bool {
|
||||||
|
if *input == Input::Enter
|
||||||
|
&& self.active.view.mode == Mode::Normal
|
||||||
|
&& self.palette.is_none()
|
||||||
|
&& self
|
||||||
|
.active
|
||||||
|
.review_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|binding| binding.kind == ReviewText::Overview)
|
||||||
|
{
|
||||||
|
if let Err(error) = self.local_review_command(Action::ReviewEdit, "") {
|
||||||
|
self.set_message(error);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod panes;
|
|||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
|
pub mod review;
|
||||||
pub mod ruby;
|
pub mod ruby;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
mod snippet;
|
mod snippet;
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
|||||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||||
|
Local reviews: :diff.comment at caret; use palette or Visual binding for a selected line range
|
||||||
|
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
|
||||||
|
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
|
||||||
|
:diff.review.save PATH / :diff.review.open PATH; :w in review panes saves local JSON
|
||||||
|
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
||||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
||||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||||
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@ struct PreparedComparison {
|
|||||||
id: u64,
|
id: u64,
|
||||||
selected: [Revision; 2],
|
selected: [Revision; 2],
|
||||||
selected_ids: [Option<String>; 2],
|
selected_ids: [Option<String>; 2],
|
||||||
|
revisions: [String; 2],
|
||||||
mode: ComparisonMode,
|
mode: ComparisonMode,
|
||||||
left: Snapshot,
|
left: Snapshot,
|
||||||
right: Snapshot,
|
right: Snapshot,
|
||||||
@@ -2284,6 +2285,34 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
|
fn snapshot_revision(
|
||||||
|
&mut self,
|
||||||
|
target: &RevisionTarget,
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if let RevisionTarget::Commit(id) = target {
|
||||||
|
return Ok(id.clone());
|
||||||
|
}
|
||||||
|
self.ensure_private()?;
|
||||||
|
let store = &self.private.as_ref().unwrap().1;
|
||||||
|
let mut input = b"vamp-working-snapshot-v1\0".to_vec();
|
||||||
|
for (path, file) in snapshot {
|
||||||
|
self.check_cancelled()?;
|
||||||
|
for field in [
|
||||||
|
path.as_os_str().as_encoded_bytes(),
|
||||||
|
file.mode.as_bytes(),
|
||||||
|
file.bytes.as_slice(),
|
||||||
|
file.notice.as_deref().unwrap_or("").as_bytes(),
|
||||||
|
] {
|
||||||
|
input.extend_from_slice(&(field.len() as u64).to_be_bytes());
|
||||||
|
input.extend_from_slice(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut command = git_command(store);
|
||||||
|
command.args(["hash-object", "--no-filters", "--stdin"]);
|
||||||
|
let id = text(checked(run(command, input, &self.cancelled)?)?)?;
|
||||||
|
Ok(format!("working:{}", id.trim()))
|
||||||
|
}
|
||||||
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
|
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
|
||||||
if *target == RevisionTarget::Working {
|
if *target == RevisionTarget::Working {
|
||||||
self.working_snapshot(&[])
|
self.working_snapshot(&[])
|
||||||
@@ -2454,10 +2483,17 @@ impl Repository {
|
|||||||
let (store, id) = pinned.as_ref().unwrap();
|
let (store, id) = pinned.as_ref().unwrap();
|
||||||
self.commit_snapshot(store, id)?
|
self.commit_snapshot(store, id)?
|
||||||
};
|
};
|
||||||
|
let before_revision = self.snapshot_revision(before_target, &left)?;
|
||||||
|
let after_revision = if same {
|
||||||
|
before_revision.clone()
|
||||||
|
} else {
|
||||||
|
self.snapshot_revision(after_target, &right)?
|
||||||
|
};
|
||||||
let mut prepared = PreparedComparison {
|
let mut prepared = PreparedComparison {
|
||||||
id: comparison_id,
|
id: comparison_id,
|
||||||
selected: [before.clone(), after.clone()],
|
selected: [before.clone(), after.clone()],
|
||||||
selected_ids,
|
selected_ids,
|
||||||
|
revisions: [before_revision, after_revision],
|
||||||
mode,
|
mode,
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
@@ -2574,9 +2610,11 @@ impl Repository {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let right = self.working_snapshot(buffers)?;
|
let right = self.working_snapshot(buffers)?;
|
||||||
|
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
|
||||||
let mut prepared = self.comparison.take().unwrap();
|
let mut prepared = self.comparison.take().unwrap();
|
||||||
let previous_right = std::mem::replace(&mut prepared.right, right);
|
let previous_right = std::mem::replace(&mut prepared.right, right);
|
||||||
let previous_files = std::mem::take(&mut prepared.files);
|
let previous_files = std::mem::take(&mut prepared.files);
|
||||||
|
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision);
|
||||||
let result = self
|
let result = self
|
||||||
.build_comparison(&mut prepared, false)
|
.build_comparison(&mut prepared, false)
|
||||||
.and_then(|comparison| {
|
.and_then(|comparison| {
|
||||||
@@ -2598,6 +2636,7 @@ impl Repository {
|
|||||||
} else {
|
} else {
|
||||||
prepared.right = previous_right;
|
prepared.right = previous_right;
|
||||||
prepared.files = previous_files;
|
prepared.files = previous_files;
|
||||||
|
prepared.revisions[1] = previous_revision;
|
||||||
}
|
}
|
||||||
self.comparison = Some(prepared);
|
self.comparison = Some(prepared);
|
||||||
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
|
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
|
||||||
@@ -2628,11 +2667,14 @@ impl Repository {
|
|||||||
let advance_working = working_after
|
let advance_working = working_after
|
||||||
&& follow_working
|
&& follow_working
|
||||||
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
|
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
|
||||||
|
let mut revisions = prepared.revisions.clone();
|
||||||
let (left, before_label) = if branch {
|
let (left, before_label) = if branch {
|
||||||
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
|
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}");
|
let label = format!("merge base of {label} and {after_label} · {base}");
|
||||||
|
revisions[0] = base.clone();
|
||||||
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
|
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
|
||||||
} else if changed_before {
|
} else if changed_before {
|
||||||
|
revisions[0] = ids[0].clone().unwrap();
|
||||||
(
|
(
|
||||||
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
|
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
|
||||||
label,
|
label,
|
||||||
@@ -2647,6 +2689,13 @@ impl Repository {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
if let Some(right) = &right {
|
||||||
|
revisions[1] = if advance_working {
|
||||||
|
self.snapshot_revision(&RevisionTarget::Working, right)?
|
||||||
|
} else {
|
||||||
|
ids[1].clone().unwrap()
|
||||||
|
};
|
||||||
|
}
|
||||||
let pinned_checkout = ids[1]
|
let pinned_checkout = ids[1]
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|id| self.pinned_checkout(id))
|
.map(|id| self.pinned_checkout(id))
|
||||||
@@ -2658,6 +2707,7 @@ impl Repository {
|
|||||||
let previous_files = std::mem::take(&mut prepared.files);
|
let previous_files = std::mem::take(&mut prepared.files);
|
||||||
let previous_before = std::mem::replace(&mut prepared.before, before_label);
|
let previous_before = std::mem::replace(&mut prepared.before, before_label);
|
||||||
let previous_editable = prepared.editable_after;
|
let previous_editable = prepared.editable_after;
|
||||||
|
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
|
||||||
prepared.editable_after =
|
prepared.editable_after =
|
||||||
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
|
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
|
||||||
let result = self
|
let result = self
|
||||||
@@ -2693,6 +2743,7 @@ impl Repository {
|
|||||||
prepared.files = previous_files;
|
prepared.files = previous_files;
|
||||||
prepared.before = previous_before;
|
prepared.before = previous_before;
|
||||||
prepared.editable_after = previous_editable;
|
prepared.editable_after = previous_editable;
|
||||||
|
prepared.revisions = previous_revisions;
|
||||||
}
|
}
|
||||||
self.comparison = Some(prepared);
|
self.comparison = Some(prepared);
|
||||||
result
|
result
|
||||||
@@ -2810,6 +2861,7 @@ impl Repository {
|
|||||||
BTreeSet::new()
|
BTreeSet::new()
|
||||||
};
|
};
|
||||||
Comparison {
|
Comparison {
|
||||||
|
revisions: prepared.revisions.clone(),
|
||||||
before: prepared.before.clone(),
|
before: prepared.before.clone(),
|
||||||
after: prepared.after.clone(),
|
after: prepared.after.clone(),
|
||||||
files,
|
files,
|
||||||
|
|||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
collections::BTreeSet,
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::{Read, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT: usize = 64 * 1024 * 1024;
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Side {
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
}
|
||||||
|
impl Side {
|
||||||
|
pub fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Before => 0,
|
||||||
|
Self::After => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Verdict {
|
||||||
|
LooksGood,
|
||||||
|
ChangesRequested,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Anchor {
|
||||||
|
/// Frozen comparison context; `content` additionally captures any live buffer edits.
|
||||||
|
pub revision: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub side: Side,
|
||||||
|
pub start_line: usize,
|
||||||
|
pub end_line: usize,
|
||||||
|
/// Exact whole-side text, including its final newline when present.
|
||||||
|
// ponytail: whole-file text per comment; deduplicate snapshots if the 64 MiB ceiling matters.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
impl Anchor {
|
||||||
|
pub fn matches(&self, revision: &str, path: &Path, side: Side, text: &str) -> bool {
|
||||||
|
self.revision == revision && self.path == path && self.side == side && self.content == text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches_lines(
|
||||||
|
&self,
|
||||||
|
revision: &str,
|
||||||
|
path: &Path,
|
||||||
|
side: Side,
|
||||||
|
lines: &[String],
|
||||||
|
eof: bool,
|
||||||
|
) -> bool {
|
||||||
|
self.revision == revision
|
||||||
|
&& self.path == path
|
||||||
|
&& self.side == side
|
||||||
|
&& self.content.split('\n').eq(lines
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.chain((eof || lines.is_empty()).then_some("")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Comment {
|
||||||
|
pub id: u64,
|
||||||
|
pub anchor: Anchor,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Review {
|
||||||
|
pub version: u32,
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub summary: String,
|
||||||
|
pub verdict: Option<Verdict>,
|
||||||
|
pub comments: Vec<Comment>,
|
||||||
|
#[serde(skip)]
|
||||||
|
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
|
||||||
|
}
|
||||||
|
impl Default for Review {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(PathBuf::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ReviewState {
|
||||||
|
pub document: Review,
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
pub selected: Option<u64>,
|
||||||
|
pub pending_navigation: Option<u64>,
|
||||||
|
pub dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Review {
|
||||||
|
pub fn new(root: PathBuf) -> Self {
|
||||||
|
let root = if root.as_os_str().is_empty() {
|
||||||
|
root
|
||||||
|
} else {
|
||||||
|
fs::canonicalize(&root).unwrap_or(root)
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
root,
|
||||||
|
summary: String::new(),
|
||||||
|
verdict: None,
|
||||||
|
comments: Vec::new(),
|
||||||
|
saved: RefCell::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.version != 1 {
|
||||||
|
return Err(format!("Unsupported review version {}", self.version));
|
||||||
|
}
|
||||||
|
if !self.root.is_absolute() || !self.root.is_dir() {
|
||||||
|
return Err("Review repository root must be an existing absolute directory".into());
|
||||||
|
}
|
||||||
|
let mut ids = BTreeSet::new();
|
||||||
|
for comment in &self.comments {
|
||||||
|
if comment.id == 0 || !ids.insert(comment.id) {
|
||||||
|
return Err("Review comment IDs must be unique and nonzero".into());
|
||||||
|
}
|
||||||
|
let anchor = &comment.anchor;
|
||||||
|
crate::repository::safe_path(&anchor.path)?;
|
||||||
|
if anchor.path.to_str().is_none_or(|path| path.contains('\0')) {
|
||||||
|
return Err("Review paths must be UTF-8 without NUL bytes".into());
|
||||||
|
}
|
||||||
|
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
|
||||||
|
return Err("Review anchors require an immutable revision identity".into());
|
||||||
|
}
|
||||||
|
if anchor.start_line == 0
|
||||||
|
|| anchor.end_line < anchor.start_line
|
||||||
|
|| anchor.end_line > anchor.content.split('\n').count()
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Review ranges must be inclusive, nonzero and within the anchor text".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path, root: &Path) -> Result<Self, String> {
|
||||||
|
let bytes = read_file(path)?.ok_or("Review file does not exist")?;
|
||||||
|
let mut review: Self = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|error| format!("Invalid review JSON: {error}"))?;
|
||||||
|
review.validate()?;
|
||||||
|
let expected = fs::canonicalize(root).map_err(|error| error.to_string())?;
|
||||||
|
let actual = fs::canonicalize(&review.root).map_err(|error| error.to_string())?;
|
||||||
|
if actual != expected {
|
||||||
|
return Err("Review belongs to a different repository".into());
|
||||||
|
}
|
||||||
|
review.root = actual;
|
||||||
|
*review.saved.borrow_mut() = Some((identity(path)?, Arc::new(bytes)));
|
||||||
|
Ok(review)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||||||
|
self.validate()?;
|
||||||
|
let bytes = serde_json::to_vec_pretty(self).map_err(|error| error.to_string())?;
|
||||||
|
if bytes.len() > LIMIT {
|
||||||
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
|
}
|
||||||
|
let path = identity(path)?;
|
||||||
|
let saved = self.saved.borrow();
|
||||||
|
let expected = saved
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(previous, _)| previous == &path)
|
||||||
|
.map(|(_, bytes)| bytes.as_slice());
|
||||||
|
check_file(&path, expected)?;
|
||||||
|
let parent = path.parent().ok_or("Review path has no parent")?;
|
||||||
|
let (temporary, mut file) = loop {
|
||||||
|
let id = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let temporary = parent.join(format!(".vamp-review-{}-{id}.tmp", std::process::id()));
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
match options.open(&temporary) {
|
||||||
|
Ok(file) => break (temporary, file),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(error) => return Err(error.to_string()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result: Result<(), String> = (|| {
|
||||||
|
file.write_all(&bytes)
|
||||||
|
.and_then(|_| file.sync_all())
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
drop(file);
|
||||||
|
check_file(&path, expected)?;
|
||||||
|
if expected.is_some() {
|
||||||
|
// ponytail: optimistic check like editor saves; OS compare-and-swap for hostile writers.
|
||||||
|
fs::rename(&temporary, &path).map_err(|error| error.to_string())?;
|
||||||
|
} else {
|
||||||
|
// Publishing a new file must not replace a concurrently created target.
|
||||||
|
fs::hard_link(&temporary, &path).map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
result?;
|
||||||
|
drop(saved);
|
||||||
|
*self.saved.borrow_mut() = Some((path, Arc::new(bytes)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity(path: &Path) -> Result<PathBuf, String> {
|
||||||
|
// Resolve the parent only: never silently follow a target symlink.
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.filter(|path| !path.as_os_str().is_empty())
|
||||||
|
.unwrap_or(Path::new("."));
|
||||||
|
let name = path.file_name().ok_or("Review path must name a file")?;
|
||||||
|
Ok(fs::canonicalize(parent)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file(path: &Path) -> Result<Option<Vec<u8>>, String> {
|
||||||
|
let metadata = match fs::symlink_metadata(path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(error.to_string()),
|
||||||
|
};
|
||||||
|
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||||
|
return Err("Review file must be a regular file, not a symbolic link".into());
|
||||||
|
}
|
||||||
|
if metadata.len() > LIMIT as u64 {
|
||||||
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
fs::File::open(path)
|
||||||
|
.and_then(|file| file.take(LIMIT as u64 + 1).read_to_end(&mut bytes))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if bytes.len() > LIMIT {
|
||||||
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
|
}
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_file(path: &Path, expected: Option<&[u8]>) -> Result<(), String> {
|
||||||
|
let actual = read_file(path)?;
|
||||||
|
if actual.as_deref() != expected {
|
||||||
|
return Err(
|
||||||
|
"Review file changed outside vamp or already exists; open it before saving".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct Directory(PathBuf);
|
||||||
|
impl Drop for Directory {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||||
|
let directory = Directory(std::env::temp_dir().join(format!(
|
||||||
|
"vamp-review-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
|
||||||
|
)));
|
||||||
|
fs::create_dir(&directory.0).unwrap();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let path = root.join("review.json");
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
review.summary = "Local review".into();
|
||||||
|
review.verdict = Some(Verdict::ChangesRequested);
|
||||||
|
review.comments.push(Comment {
|
||||||
|
id: 1,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "working:immutable".into(),
|
||||||
|
path: "source.rs".into(),
|
||||||
|
side: Side::After,
|
||||||
|
start_line: 1,
|
||||||
|
end_line: 2,
|
||||||
|
content: "first\nlast\n".into(),
|
||||||
|
},
|
||||||
|
body: "Please adjust this".into(),
|
||||||
|
});
|
||||||
|
review.save(&path).unwrap();
|
||||||
|
let mut loaded = Review::load(&path, &root).unwrap();
|
||||||
|
assert_eq!(loaded.comments, review.comments);
|
||||||
|
assert_eq!(loaded.verdict, review.verdict);
|
||||||
|
let anchor = &loaded.comments[0].anchor;
|
||||||
|
assert!(anchor.matches(
|
||||||
|
"working:immutable",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
"first\nlast\n"
|
||||||
|
));
|
||||||
|
assert!(!anchor.matches(
|
||||||
|
"working:other",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
"first\nlast\n"
|
||||||
|
));
|
||||||
|
assert!(anchor.matches_lines(
|
||||||
|
"working:immutable",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
&["first".into(), "last".into()],
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!anchor.matches_lines(
|
||||||
|
"working:immutable",
|
||||||
|
Path::new("source.rs"),
|
||||||
|
Side::After,
|
||||||
|
&["first".into(), "last".into()],
|
||||||
|
false
|
||||||
|
));
|
||||||
|
loaded.summary = "Updated".into();
|
||||||
|
loaded.save(&path).unwrap();
|
||||||
|
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
|
||||||
|
assert!(Review::new(root.clone()).save(&path).is_err());
|
||||||
|
let valid = fs::read(&path).unwrap();
|
||||||
|
for (field, value) in [
|
||||||
|
("version", serde_json::json!(2)),
|
||||||
|
("root", serde_json::json!(root.join("other"))),
|
||||||
|
(
|
||||||
|
"comments",
|
||||||
|
serde_json::json!([loaded.comments[0], loaded.comments[0]]),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||||
|
invalid[field] = value;
|
||||||
|
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||||
|
assert!(Review::load(&path, &root).is_err());
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("path", serde_json::json!("../outside")),
|
||||||
|
("path", serde_json::json!("/absolute")),
|
||||||
|
("path", serde_json::json!(".git/config")),
|
||||||
|
("start_line", serde_json::json!(0)),
|
||||||
|
("start_line", serde_json::json!(3)),
|
||||||
|
("end_line", serde_json::json!(4)),
|
||||||
|
("revision", serde_json::json!("")),
|
||||||
|
] {
|
||||||
|
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||||
|
invalid["comments"][0]["anchor"][field] = value;
|
||||||
|
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||||
|
assert!(Review::load(&path, &root).is_err());
|
||||||
|
}
|
||||||
|
fs::create_dir(root.join("other")).unwrap();
|
||||||
|
fs::write(&path, &valid).unwrap();
|
||||||
|
assert!(Review::load(&path, &root.join("other")).is_err());
|
||||||
|
fs::write(&path, b"external edit").unwrap();
|
||||||
|
assert!(loaded.save(&path).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||||
|
assert_eq!(loaded.summary, "Updated");
|
||||||
|
let invalid = root.join("invalid.json");
|
||||||
|
loaded.comments[0].anchor.start_line = 0;
|
||||||
|
assert!(loaded.save(&invalid).is_err());
|
||||||
|
assert!(!invalid.exists());
|
||||||
|
let large = root.join("large.json");
|
||||||
|
fs::File::create(&large)
|
||||||
|
.unwrap()
|
||||||
|
.set_len(LIMIT as u64 + 1)
|
||||||
|
.unwrap();
|
||||||
|
assert!(Review::load(&large, &root).is_err());
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let link = root.join("link.json");
|
||||||
|
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||||
|
assert!(Review::load(&link, &root).is_err());
|
||||||
|
assert!(review.save(&link).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2226,6 +2226,7 @@ mod tests {
|
|||||||
session.editor.apply_diff_result(
|
session.editor.apply_diff_result(
|
||||||
id,
|
id,
|
||||||
Ok(DiffOutcome::Comparison(Comparison {
|
Ok(DiffOutcome::Comparison(Comparison {
|
||||||
|
revisions: ["base".into(), "after".into()],
|
||||||
root: fixture.0.clone(),
|
root: fixture.0.clone(),
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "after".into(),
|
after: "after".into(),
|
||||||
@@ -5343,6 +5344,7 @@ end
|
|||||||
session.editor.apply_diff_result(
|
session.editor.apply_diff_result(
|
||||||
id,
|
id,
|
||||||
Ok(DiffOutcome::Comparison(Comparison {
|
Ok(DiffOutcome::Comparison(Comparison {
|
||||||
|
revisions: ["base".into(), "working:snapshot".into()],
|
||||||
root: fixture.0.canonicalize().unwrap(),
|
root: fixture.0.canonicalize().unwrap(),
|
||||||
before: "base".into(),
|
before: "base".into(),
|
||||||
after: "Working".into(),
|
after: "Working".into(),
|
||||||
|
|||||||
+255
@@ -737,6 +737,9 @@ impl BufferViewports {
|
|||||||
.retain(|id, _| view.has_buffer(*id));
|
.retain(|id, _| view.has_buffer(*id));
|
||||||
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
|
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
|
||||||
let syntax = self.syntax.entry(view.buffer_id()).or_default();
|
let syntax = self.syntax.entry(view.buffer_id()).or_default();
|
||||||
|
let leaf = view.review_panel_title().map_or(leaf, |title| {
|
||||||
|
resolver_document_panel(frame, leaf, title, id == focused)
|
||||||
|
});
|
||||||
draw_document(
|
draw_document(
|
||||||
frame,
|
frame,
|
||||||
view,
|
view,
|
||||||
@@ -4324,6 +4327,22 @@ fn draw_diff_pane(
|
|||||||
*left = diff.bound_horizontal(focus, *left, width);
|
*left = diff.bound_horizontal(focus, *left, width);
|
||||||
}
|
}
|
||||||
let left = *left;
|
let left = *left;
|
||||||
|
let review_comments = diff
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.comments
|
||||||
|
.iter()
|
||||||
|
.filter(|comment| {
|
||||||
|
let before = comment.anchor.side == crate::review::Side::Before;
|
||||||
|
let path = if before {
|
||||||
|
file.old_path.as_ref().unwrap_or(&file.path)
|
||||||
|
} else {
|
||||||
|
&file.path
|
||||||
|
};
|
||||||
|
(unified || before == (focus == Focus::Before))
|
||||||
|
&& comment.anchor.path == *path
|
||||||
|
&& diff.review_anchor_current(&comment.anchor)
|
||||||
|
});
|
||||||
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
|
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
|
||||||
let row = &diff.display_rows[display_row];
|
let row = &diff.display_rows[display_row];
|
||||||
let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1);
|
let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1);
|
||||||
@@ -4495,6 +4514,46 @@ fn draw_diff_pane(
|
|||||||
paint_trailing_whitespace(frame, &text, text_area, left);
|
paint_trailing_whitespace(frame, &text, text_area, left);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if gutter > 0 {
|
||||||
|
for comment in review_comments {
|
||||||
|
let side = match comment.anchor.side {
|
||||||
|
crate::review::Side::Before => Focus::Before,
|
||||||
|
crate::review::Side::After => Focus::After,
|
||||||
|
};
|
||||||
|
let selected = diff.local_review.selected == Some(comment.id);
|
||||||
|
for (screen, display, _) in diff_screen_rows(diff, top, content.height, wrapped) {
|
||||||
|
let source = diff.display_source_row(side, display).or_else(|| {
|
||||||
|
let DisplayRow::Line(index) = diff.display_rows[display] else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let row = &diff.rows[index];
|
||||||
|
(unified && row.kind == RowKind::Equal)
|
||||||
|
.then_some(match side {
|
||||||
|
Focus::Before => row.before,
|
||||||
|
Focus::After => row.after,
|
||||||
|
Focus::Files => None,
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
});
|
||||||
|
if wrapped.is_some_and(|rows| rows[screen].position.continuation != 0)
|
||||||
|
|| !source.is_some_and(|row| {
|
||||||
|
(comment.anchor.start_line..=comment.anchor.end_line).contains(&(row + 1))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cell = &mut frame.buffer_mut()
|
||||||
|
[(content.x + gutter as u16 - 1, content.y + screen as u16)];
|
||||||
|
if selected || cell.symbol() != "●" {
|
||||||
|
cell.set_symbol(if selected { "●" } else { "○" })
|
||||||
|
.set_fg(Color::Magenta);
|
||||||
|
if selected {
|
||||||
|
cell.modifier.insert(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Rect::new(
|
Rect::new(
|
||||||
content.x + gutter as u16,
|
content.x + gutter as u16,
|
||||||
content.y,
|
content.y,
|
||||||
@@ -8872,6 +8931,7 @@ mod tests {
|
|||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
before: "old revision".into(),
|
before: "old revision".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files,
|
files,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -9234,6 +9294,193 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn commented_diff() -> DiffState {
|
||||||
|
use crate::review::{Anchor, Comment, Side};
|
||||||
|
let before = format!("{}\nold second\nkeep", "old ".repeat(24));
|
||||||
|
let after = format!("{}\nnew second\nkeep", "new ".repeat(24));
|
||||||
|
let mut file = syntax_diff_file("new.txt", &before, &after);
|
||||||
|
file.hunks =
|
||||||
|
crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof);
|
||||||
|
file.old_path = Some("old.txt".into());
|
||||||
|
let mut diff = diff_comparison(vec![file]);
|
||||||
|
diff.files_visible = false;
|
||||||
|
diff.local_review.document.root = diff.comparison.as_ref().unwrap().root.clone();
|
||||||
|
diff.local_review.document.comments = vec![
|
||||||
|
Comment {
|
||||||
|
id: 1,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "before-snapshot".into(),
|
||||||
|
path: "old.txt".into(),
|
||||||
|
side: Side::Before,
|
||||||
|
start_line: 1,
|
||||||
|
end_line: 2,
|
||||||
|
content: before,
|
||||||
|
},
|
||||||
|
body: "Before range".into(),
|
||||||
|
},
|
||||||
|
Comment {
|
||||||
|
id: 2,
|
||||||
|
anchor: Anchor {
|
||||||
|
revision: "after-snapshot".into(),
|
||||||
|
path: "new.txt".into(),
|
||||||
|
side: Side::After,
|
||||||
|
start_line: 1,
|
||||||
|
end_line: 1,
|
||||||
|
content: after,
|
||||||
|
},
|
||||||
|
body: "After line".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
diff.local_review.selected = Some(1);
|
||||||
|
diff
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() {
|
||||||
|
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||||
|
for wrapping in [false, true] {
|
||||||
|
let mut diff = commented_diff();
|
||||||
|
diff.set_view_mode(mode);
|
||||||
|
diff.top = 0;
|
||||||
|
diff.word_wrap = wrapping;
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
let markers: Vec<_> = buffer
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, cell)| matches!(cell.symbol(), "●" | "○"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers.len(), 3, "{mode:?} {wrapping}");
|
||||||
|
assert_eq!(
|
||||||
|
markers
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, cell)| cell.symbol() == "●")
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
for &(offset, cell) in &markers {
|
||||||
|
assert_eq!(cell.fg, Color::Magenta);
|
||||||
|
assert_eq!(cell.modifier.contains(Modifier::BOLD), cell.symbol() == "●");
|
||||||
|
assert_eq!(
|
||||||
|
buffer.content[offset - 1].symbol(),
|
||||||
|
if cell.symbol() == "●" { "-" } else { "+" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if wrapping {
|
||||||
|
let rows = &panels.diff_wrap.as_ref().unwrap().rows;
|
||||||
|
assert!(rows.iter().any(|row| row.position.continuation > 0));
|
||||||
|
for (area, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) {
|
||||||
|
let area = diff_inner(area);
|
||||||
|
for (screen, row) in rows.iter().take(area.height as usize).enumerate() {
|
||||||
|
if row.position.continuation > 0 {
|
||||||
|
for x in area.x..area.right() {
|
||||||
|
assert!(!matches!(
|
||||||
|
buffer[(x, area.y + screen as u16)].symbol(),
|
||||||
|
"●" | "○"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_review_markers_reject_stale_identity_text_and_rename_paths() {
|
||||||
|
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||||
|
for mismatch in ["revision", "content", "path", "side", "root"] {
|
||||||
|
let mut diff = commented_diff();
|
||||||
|
diff.set_view_mode(mode);
|
||||||
|
diff.local_review.document.comments.truncate(1);
|
||||||
|
let anchor = &mut diff.local_review.document.comments[0].anchor;
|
||||||
|
match mismatch {
|
||||||
|
"revision" => anchor.revision.push_str("-newer"),
|
||||||
|
"content" => anchor.content.push('\n'),
|
||||||
|
"path" => anchor.path = "new.txt".into(),
|
||||||
|
"side" => anchor.side = crate::review::Side::After,
|
||||||
|
"root" => diff.local_review.document.root = "another-repository".into(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
|
||||||
|
render_diff(&mut terminal, &diff, &mut WheelPanels::default());
|
||||||
|
assert!(
|
||||||
|
terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.all(|cell| !matches!(cell.symbol(), "●" | "○")),
|
||||||
|
"{mode:?} {mismatch}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_context_shows_comments_from_either_snapshot_side() {
|
||||||
|
for side in [crate::review::Side::Before, crate::review::Side::After] {
|
||||||
|
let mut diff = commented_diff();
|
||||||
|
diff.set_view_mode(ViewMode::Unified);
|
||||||
|
diff.local_review
|
||||||
|
.document
|
||||||
|
.comments
|
||||||
|
.retain(|comment| comment.anchor.side == side);
|
||||||
|
let comment = &mut diff.local_review.document.comments[0];
|
||||||
|
comment.anchor.start_line = 3;
|
||||||
|
comment.anchor.end_line = 3;
|
||||||
|
diff.local_review.selected = Some(comment.id);
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
|
||||||
|
render_diff(&mut terminal, &diff, &mut WheelPanels::default());
|
||||||
|
assert!(
|
||||||
|
screen_text(&terminal).contains("●keep"),
|
||||||
|
"{side:?}: {}",
|
||||||
|
screen_text(&terminal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_review_markers_yield_to_text_on_narrow_surfaces() {
|
||||||
|
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||||
|
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (16, 8), (30, 12)] {
|
||||||
|
let mut diff = commented_diff();
|
||||||
|
diff.set_view_mode(mode);
|
||||||
|
diff.word_wrap = true;
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
for (area, focus) in panels
|
||||||
|
.diff
|
||||||
|
.as_ref()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|geometry| geometry.regions.into_iter().skip(1))
|
||||||
|
{
|
||||||
|
assert!(area.right() <= width && area.bottom() <= height);
|
||||||
|
let content = diff_inner(area);
|
||||||
|
let file = &diff.comparison.as_ref().unwrap().files[0];
|
||||||
|
let gutter = diff_gutter(&diff, file, content.width as usize, focus);
|
||||||
|
for y in content.y..content.bottom() {
|
||||||
|
for x in content.x..content.right() {
|
||||||
|
if matches!(terminal.backend().buffer()[(x, y)].symbol(), "●" | "○")
|
||||||
|
{
|
||||||
|
assert!(gutter > 0);
|
||||||
|
assert_eq!(x, content.x + gutter as u16 - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const LIGHT_DIFF_THEME: ThemeColors = ThemeColors {
|
const LIGHT_DIFF_THEME: ThemeColors = ThemeColors {
|
||||||
background: [250, 245, 240],
|
background: [250, 245, 240],
|
||||||
red: [210, 30, 50],
|
red: [210, 30, 50],
|
||||||
@@ -10658,6 +10905,7 @@ mod tests {
|
|||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
before: "old revision".into(),
|
before: "old revision".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![deletion_file()],
|
files: vec![deletion_file()],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -11480,6 +11728,7 @@ end
|
|||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
before: "baseline".into(),
|
before: "baseline".into(),
|
||||||
after: "historic".into(),
|
after: "historic".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![file],
|
files: vec![file],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -11573,6 +11822,7 @@ end
|
|||||||
editable_paths: ["live.rs".into()].into(),
|
editable_paths: ["live.rs".into()].into(),
|
||||||
before: "baseline".into(),
|
before: "baseline".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![syntax_diff_file("live.rs", "old", "new")],
|
files: vec![syntax_diff_file("live.rs", "old", "new")],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -12208,6 +12458,7 @@ end
|
|||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
before: "baseline".into(),
|
before: "baseline".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![deletion_file()],
|
files: vec![deletion_file()],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -13617,6 +13868,7 @@ end
|
|||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
before: "baseline".into(),
|
before: "baseline".into(),
|
||||||
after: "historic".into(),
|
after: "historic".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![source, unloaded],
|
files: vec![source, unloaded],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -13714,6 +13966,7 @@ end
|
|||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
before: "baseline".into(),
|
before: "baseline".into(),
|
||||||
after: "historic".into(),
|
after: "historic".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![file],
|
files: vec![file],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -14336,6 +14589,7 @@ end
|
|||||||
},
|
},
|
||||||
before: "baseline".into(),
|
before: "baseline".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![syntax_diff_file(
|
files: vec![syntax_diff_file(
|
||||||
"live.rs",
|
"live.rs",
|
||||||
"let value = 1;",
|
"let value = 1;",
|
||||||
@@ -14462,6 +14716,7 @@ end
|
|||||||
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
|
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
|
||||||
before: "baseline".into(),
|
before: "baseline".into(),
|
||||||
after: "Working checkout".into(),
|
after: "Working checkout".into(),
|
||||||
|
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||||
files: vec![syntax_diff_file(
|
files: vec![syntax_diff_file(
|
||||||
"live.rs",
|
"live.rs",
|
||||||
"let value = \"old\";",
|
"let value = \"old\";",
|
||||||
|
|||||||
@@ -195,6 +195,75 @@ fn disk_state(root: &Path) -> BTreeMap<PathBuf, (u32, Vec<u8>)> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_revisions_freeze_commits_and_address_working_contents() {
|
||||||
|
for kind in [RepoKind::Git, RepoKind::Jujutsu] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let root = fixture.dir("identity").canonicalize().unwrap();
|
||||||
|
match kind {
|
||||||
|
RepoKind::Git => init_git(&root),
|
||||||
|
RepoKind::Jujutsu => init_jj(&root, false),
|
||||||
|
}
|
||||||
|
fs::write(root.join("file"), "committed\n").unwrap();
|
||||||
|
let id = match kind {
|
||||||
|
RepoKind::Git => {
|
||||||
|
git(&root, &["add", "."]);
|
||||||
|
git(&root, &["commit", "-m", "baseline"]);
|
||||||
|
git(&root, &["rev-parse", "HEAD"])
|
||||||
|
}
|
||||||
|
RepoKind::Jujutsu => {
|
||||||
|
jj(&root, &["describe", "-m", "baseline"]);
|
||||||
|
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs::write(root.join("file"), "working\n").unwrap();
|
||||||
|
let state = disk_state(&root);
|
||||||
|
let before = revision(&id, kind);
|
||||||
|
let after = working(kind);
|
||||||
|
let mut repository = open(&root);
|
||||||
|
let first = repository
|
||||||
|
.compare(1, &before, &after, ComparisonMode::Snapshots, &[])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.revisions[0], id);
|
||||||
|
assert!(first.revisions[1].starts_with("working:"));
|
||||||
|
let reopened = open(&root)
|
||||||
|
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.revisions, reopened.revisions);
|
||||||
|
let overlay = repository
|
||||||
|
.compare(
|
||||||
|
3,
|
||||||
|
&before,
|
||||||
|
&after,
|
||||||
|
ComparisonMode::Snapshots,
|
||||||
|
&[(root.join("file"), "unsaved\n".into())],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(overlay.revisions[1], first.revisions[1]);
|
||||||
|
let repeated = repository
|
||||||
|
.compare(
|
||||||
|
4,
|
||||||
|
&before,
|
||||||
|
&after,
|
||||||
|
ComparisonMode::Snapshots,
|
||||||
|
&[(root.join("file"), "unsaved\n".into())],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(repeated.revisions, overlay.revisions);
|
||||||
|
let working_pair = repository
|
||||||
|
.compare(5, &after, &after, ComparisonMode::Snapshots, &[])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(working_pair.revisions[0], first.revisions[1]);
|
||||||
|
assert_eq!(working_pair.revisions[0], working_pair.revisions[1]);
|
||||||
|
assert_eq!(disk_state(&root), state);
|
||||||
|
fs::write(root.join("file"), "working").unwrap();
|
||||||
|
let no_newline = repository
|
||||||
|
.compare(6, &before, &after, ComparisonMode::Snapshots, &[])
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(no_newline.revisions[1], first.revisions[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() {
|
fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -874,6 +943,7 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
|
|||||||
.compare(1, before, after, ComparisonMode::BranchChanges, &[])
|
.compare(1, before, after, ComparisonMode::BranchChanges, &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(original.before.ends_with(&base));
|
assert!(original.before.ends_with(&base));
|
||||||
|
assert_eq!(original.revisions, [base.clone(), tip.clone()]);
|
||||||
jj(&root, &["bookmark", "set", "before", "-r", &tip]);
|
jj(&root, &["bookmark", "set", "before", "-r", &tip]);
|
||||||
let Some(CheckoutRefresh::Bookmark(comparison)) =
|
let Some(CheckoutRefresh::Bookmark(comparison)) =
|
||||||
repository.refresh_checkout(1, 2, false, &[]).unwrap()
|
repository.refresh_checkout(1, 2, false, &[]).unwrap()
|
||||||
@@ -881,6 +951,7 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
|
|||||||
panic!("Moved Before must recompute merge base");
|
panic!("Moved Before must recompute merge base");
|
||||||
};
|
};
|
||||||
assert!(comparison.before.ends_with(&tip));
|
assert!(comparison.before.ends_with(&tip));
|
||||||
|
assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]);
|
||||||
assert!(comparison.files.is_empty());
|
assert!(comparison.files.is_empty());
|
||||||
jj(&root, &["bookmark", "delete", "after"]);
|
jj(&root, &["bookmark", "delete", "after"]);
|
||||||
let state = disk_state(&root);
|
let state = disk_state(&root);
|
||||||
|
|||||||
Reference in New Issue
Block a user