feat: show jj ancestry in the floating diff picker
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
- [x] improve: diff picker - if jj managed, present as jj log.
|
||||
- this makes it much easier to determine which revisions to actually pick for before/after
|
||||
- the bookmarks only view could simply filter out unbookmarked revisions but otherwise display in the same way
|
||||
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||
|
||||
@@ -113,7 +113,7 @@ Indicators compare the current editor text (including unsaved edits) against Git
|
||||
|
||||
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
|
||||
|
||||
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
||||
`config.diff_follow_working_copy` defaults to `true`. The After picker calls its live-files choice **Follow checkout** when enabled, otherwise **Checkout snapshot**. Following advances to descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Before is always frozen. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
||||
|
||||
`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol` control the markers before PR numbers in the pull-request picker. Defaults are `"🙏"` for an outstanding personal review request and `"👍"` for your active approval, using the API-authenticated account. Requests take precedence over approvals when both apply. Each option accepts a string without control characters; multi-character and wide Unicode symbols are supported. Set either to `""` to hide that state, or both to `""` to remove the marker gutter. PR numbers and metadata stay aligned by terminal-cell width, including rows without a marker. Non-strings and strings containing tabs, newlines, or other control characters reject the configuration.
|
||||
|
||||
@@ -352,7 +352,7 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
|
||||
|
||||
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||
|
||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots.
|
||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Checkout snapshot or Follow checkout forces Snapshots.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
+10
-6
@@ -390,16 +390,20 @@ The project root is the working directory captured at startup. Scanning uses the
|
||||
|
||||
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
|
||||
|
||||
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both Before and After support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields in any order; a single token cannot span unrelated fields. Exact, prefix, and contiguous matches rank ahead of scattered subsequences; otherwise equal matches favor names/IDs over descriptions, word boundaries, and tighter spans. Ties retain catalogue order, and an empty or whitespace-only query restores the original order. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
|
||||
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. jj results use a log graph: `@` marks the recorded checkout revision, `◆` immutable revisions, and `○` other revisions. Each revision shows its short change ID, author, date, bookmarks, commit ID, and description. Bookmarks uses the same graph with unbookmarked revisions omitted; `~` marks skipped ancestry. Multiple bookmarks on one revision remain individually selectable, preserving their symbolic follow behavior. Narrow windows use compact single-line entries.
|
||||
|
||||
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
|
||||
Both stages support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields; a token cannot span unrelated fields. jj keeps graph order while filtering. Git ranks exact, prefix, and contiguous matches ahead of scattered subsequences, with names/IDs ahead of descriptions and catalogue order breaking ties. An empty query restores catalogue order. After choosing Before, its revision carries a **[Before]** badge instead of a separate summary row. It remains included through search and category changes, but scrolls normally.
|
||||
|
||||
The separate checkout choice stays first in every category regardless of the query. It is **Checkout snapshot** during Before selection: a frozen disk snapshot including eligible untracked files. During After selection it is **Follow checkout** when `config.diff_follow_working_copy` is enabled, otherwise **Checkout snapshot**. Either After choice includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them. Unlike this live-files choice, selecting `@` in the graph pins the recorded revision.
|
||||
|
||||
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Checkout snapshot** or **Follow checkout** immediately forces **Snapshots**; Branch changes is unavailable while either is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
|
||||
|
||||
| Selector input | Effect |
|
||||
| --- | --- |
|
||||
| Tab / Shift+Tab | Next / previous category |
|
||||
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
||||
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
||||
| Ctrl+b | Toggle Branch changes / Snapshots (Working checkout requires Snapshots) |
|
||||
| Ctrl+b | Toggle Branch changes / Snapshots (checkout choices require Snapshots) |
|
||||
| Enter | Choose the entry |
|
||||
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
|
||||
|
||||
@@ -443,9 +447,9 @@ Comparison discovery builds the changed-path/status catalog first. File text, no
|
||||
|
||||
Conflicted jj revisions also discover files without reading every blob. Ordinary files and symbolic links retain their Git contents; selected conflicted files show jj's rendered conflict projection. Unchanged links and jj's internal merge-tree metadata do not appear as changes. Viewing a conflict does not resolve it or modify the revision.
|
||||
|
||||
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following.
|
||||
**Follow checkout After follows descendant checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following; the picker then calls this choice **Checkout snapshot**.
|
||||
|
||||
**Selected jj bookmarks follow their locally known targets automatically**, checked on the same one-second interval, including inactive tabs and with `config.diff_follow_working_copy = false`. Local bookmarks and known remote-tracking bookmarks retain their names; explicit revision selections remain pinned. A changed commit ID, including an amendment of the same change ID, rebuilds the comparison. Branch changes also recomputes the merge base. Unrelated endpoints stay frozen, except for the existing Working After forward-follow behavior. Deleted or conflicted bookmarks keep the last successful comparison visible with an error; a later successful check clears the error. These checks never fetch, contact remotes, or snapshot jj's working copy; privately loaded remote history and advertised remote selections remain frozen.
|
||||
**Selected jj bookmarks follow their locally known targets automatically**, checked on the same one-second interval, including inactive tabs and with `config.diff_follow_working_copy = false`. Local bookmarks and known remote-tracking bookmarks retain their names; explicit revision selections remain pinned. A changed commit ID, including an amendment of the same change ID, rebuilds the comparison. Branch changes also recomputes the merge base. Unrelated endpoints stay frozen, except for the existing Follow checkout After behavior. Deleted or conflicted bookmarks keep the last successful comparison visible with an error; a later successful check clears the error. These checks never fetch, contact remotes, or snapshot jj's working copy; privately loaded remote history and advertised remote selections remain frozen.
|
||||
|
||||
Deleted or conflicted bookmarks unrelated to the selected endpoints do not block opening or refreshing a comparison.
|
||||
|
||||
@@ -453,7 +457,7 @@ Deleted or conflicted bookmarks unrelated to the selected endpoints do not block
|
||||
|
||||
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving a Working or bookmark endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Bookmark replacements preserve/clamp cursor and scroll positions as replacement content loads. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
||||
|
||||
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. **Working checkout** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. A **Follow checkout / Checkout snapshot After** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
|
||||
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
|
||||
|
||||
|
||||
+248
-3
@@ -96,10 +96,22 @@ pub struct Revision {
|
||||
pub description: String,
|
||||
pub category: Category,
|
||||
}
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct RevisionGraph {
|
||||
pub change_id: String,
|
||||
pub description: String,
|
||||
pub parents: Vec<String>,
|
||||
pub author: String,
|
||||
pub timestamp: String,
|
||||
pub bookmarks: String,
|
||||
pub current: bool,
|
||||
pub immutable: bool,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Catalog {
|
||||
pub kind: RepoKind,
|
||||
pub entries: Vec<Revision>,
|
||||
pub graph: BTreeMap<String, RevisionGraph>,
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
@@ -761,6 +773,7 @@ pub struct DiffState {
|
||||
pub word_wrap: bool,
|
||||
pub view_mode: ViewMode,
|
||||
pub comparison_mode: ComparisonMode,
|
||||
pub follow_working_copy: bool,
|
||||
pub split_axis: crate::panes::SplitAxis,
|
||||
pub writable_side: Option<Focus>,
|
||||
pub side_labels: [&'static str; 2],
|
||||
@@ -808,6 +821,8 @@ pub struct DiffState {
|
||||
pending: Option<Operation>,
|
||||
request_kind: Option<RequestKind>,
|
||||
visible: Vec<usize>,
|
||||
picker_graph: BTreeMap<String, [String; 2]>,
|
||||
bookmark_graph: BTreeMap<String, BTreeMap<Option<String>, String>>,
|
||||
search_fields: Vec<[String; 3]>,
|
||||
search_matches: Vec<([usize; 7], usize)>,
|
||||
max_before: usize,
|
||||
@@ -831,6 +846,7 @@ impl DiffState {
|
||||
word_wrap: false,
|
||||
view_mode: ViewMode::Split,
|
||||
comparison_mode: ComparisonMode::default(),
|
||||
follow_working_copy: false,
|
||||
split_axis: crate::panes::SplitAxis::Vertical,
|
||||
writable_side: Some(Focus::After),
|
||||
side_labels: ["Before", "After"],
|
||||
@@ -875,6 +891,8 @@ impl DiffState {
|
||||
pending: Some(Operation::Discover),
|
||||
request_kind: Some(RequestKind::Catalog),
|
||||
visible: Vec::new(),
|
||||
picker_graph: BTreeMap::new(),
|
||||
bookmark_graph: BTreeMap::new(),
|
||||
search_fields: Vec::new(),
|
||||
search_matches: Vec::new(),
|
||||
max_before: 0,
|
||||
@@ -1178,6 +1196,7 @@ impl DiffState {
|
||||
kind: page.kind,
|
||||
entries: Vec::new(),
|
||||
notice: Some(format!("Pull requests from {}", page.remote)),
|
||||
graph: BTreeMap::new(),
|
||||
});
|
||||
for (revision, metadata) in page.entries {
|
||||
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
|
||||
@@ -1436,6 +1455,20 @@ impl DiffState {
|
||||
true
|
||||
}
|
||||
fn prepare_search(&mut self) {
|
||||
self.bookmark_graph.clear();
|
||||
if let Some(catalog) = &self.catalog {
|
||||
for entry in &catalog.entries {
|
||||
if let RevisionTarget::Bookmark { name, remote } = &entry.target
|
||||
&& let Some(id) = entry.description.split_whitespace().nth(1)
|
||||
&& catalog.graph.contains_key(id)
|
||||
{
|
||||
self.bookmark_graph
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.insert(remote.clone(), id.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.search_fields = self
|
||||
.catalog
|
||||
.as_ref()
|
||||
@@ -1552,6 +1585,186 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn graph_revision(&self, revision: &Revision) -> Option<(&str, &RevisionGraph)> {
|
||||
let catalog = self.catalog.as_ref()?;
|
||||
let id = match &revision.target {
|
||||
RevisionTarget::Commit(id) => id.as_str(),
|
||||
RevisionTarget::Bookmark { name, remote } => {
|
||||
self.bookmark_graph.get(name)?.get(remote)?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
catalog
|
||||
.graph
|
||||
.get_key_value(id)
|
||||
.map(|(id, graph)| (id.as_str(), graph))
|
||||
}
|
||||
|
||||
pub fn picker_graph(&self) -> &BTreeMap<String, [String; 2]> {
|
||||
&self.picker_graph
|
||||
}
|
||||
|
||||
fn rebuild_picker_graph(&mut self) {
|
||||
let mut result = BTreeMap::new();
|
||||
let Some(catalog) = &self.catalog else {
|
||||
self.picker_graph = result;
|
||||
return;
|
||||
};
|
||||
let visible: Vec<_> = self
|
||||
.entries()
|
||||
.filter_map(|entry| self.graph_revision(entry))
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
let selected: BTreeSet<_> = visible.iter().copied().collect();
|
||||
if selected.is_empty() {
|
||||
self.picker_graph = result;
|
||||
return;
|
||||
}
|
||||
let mut nearest: BTreeMap<&str, BTreeMap<&str, bool>> = BTreeMap::new();
|
||||
for entry in catalog
|
||||
.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|entry| entry.category == Category::Revisions)
|
||||
{
|
||||
let Some((id, graph)) = self.graph_revision(entry) else {
|
||||
continue;
|
||||
};
|
||||
if nearest.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
let mut ancestors = BTreeMap::new();
|
||||
if selected.contains(id) {
|
||||
ancestors.insert(id, false);
|
||||
} else {
|
||||
for parent in &graph.parents {
|
||||
if let Some(parents) = nearest.get(parent.as_str()) {
|
||||
ancestors.extend(parents.keys().map(|&parent| (parent, true)));
|
||||
}
|
||||
}
|
||||
}
|
||||
nearest.insert(id, ancestors);
|
||||
}
|
||||
let mut lanes: Vec<(&str, bool)> = Vec::new();
|
||||
for id in visible {
|
||||
if result.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
let graph = &catalog.graph[id];
|
||||
let mut parents = BTreeMap::new();
|
||||
for parent in &graph.parents {
|
||||
if let Some(ancestors) = nearest.get(parent.as_str()) {
|
||||
for (&ancestor, &skipped) in ancestors {
|
||||
parents
|
||||
.entry(ancestor)
|
||||
.and_modify(|old| *old |= skipped)
|
||||
.or_insert(skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
let column = lanes
|
||||
.iter()
|
||||
.position(|&(lane, _)| lane == id)
|
||||
.unwrap_or_else(|| {
|
||||
lanes.push((id, false));
|
||||
lanes.len() - 1
|
||||
});
|
||||
let mut next = lanes.clone();
|
||||
next.remove(column);
|
||||
for (offset, (&parent, &skipped)) in parents.iter().enumerate() {
|
||||
if let Some(lane) = next.iter_mut().find(|(lane, _)| *lane == parent) {
|
||||
lane.1 |= skipped;
|
||||
} else {
|
||||
next.insert((column + offset).min(next.len()), (parent, skipped));
|
||||
}
|
||||
}
|
||||
let width = lanes.len().max(next.len()) * 2;
|
||||
let mut first = vec![' '; width];
|
||||
for (column, &(_, skipped)) in lanes.iter().enumerate() {
|
||||
first[column * 2] = if skipped { '~' } else { '│' };
|
||||
}
|
||||
first[column * 2] = if graph.current {
|
||||
'@'
|
||||
} else if graph.immutable {
|
||||
'◆'
|
||||
} else {
|
||||
'○'
|
||||
};
|
||||
// Low bits connect up/down/left/right; high bits distinguish joins from crossings.
|
||||
let mut links = vec![0u8; width];
|
||||
for (from, &(lane, _)) in lanes.iter().enumerate() {
|
||||
for (to, &(target, _)) in next.iter().enumerate() {
|
||||
if (lane == id && !parents.contains_key(target))
|
||||
|| (lane != id && lane != target)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (from, to) = (from * 2, to * 2);
|
||||
if links[from] & 1 != 0 {
|
||||
links[from] |= 16;
|
||||
}
|
||||
if links[to] & 2 != 0 {
|
||||
links[to] |= 32;
|
||||
}
|
||||
if from == to {
|
||||
links[from] |= 3;
|
||||
} else {
|
||||
links[from] |= 1 | if from < to { 8 } else { 4 };
|
||||
links[to] |= 2 | if from < to { 4 } else { 8 };
|
||||
for cell in &mut links[from.min(to) + 1..from.max(to)] {
|
||||
*cell |= 12;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut second: Vec<_> = links
|
||||
.into_iter()
|
||||
.map(|link| match link & 15 {
|
||||
3 => '│',
|
||||
5 => '┘',
|
||||
6 => '┐',
|
||||
7 => '┤',
|
||||
9 => '└',
|
||||
10 => '┌',
|
||||
11 => '├',
|
||||
12 => '─',
|
||||
13 => '┴',
|
||||
14 => '┬',
|
||||
15 => {
|
||||
if link & 48 == 0 {
|
||||
'╪'
|
||||
} else {
|
||||
'┼'
|
||||
}
|
||||
}
|
||||
_ => ' ',
|
||||
})
|
||||
.collect();
|
||||
for (column, &(_, skipped)) in next.iter().enumerate() {
|
||||
if skipped {
|
||||
second[column * 2] = '~';
|
||||
}
|
||||
}
|
||||
if !graph.parents.is_empty() && parents.is_empty() {
|
||||
second[column * 2] = '~';
|
||||
}
|
||||
if !parents.is_empty()
|
||||
&& graph
|
||||
.parents
|
||||
.iter()
|
||||
.any(|parent| nearest.get(parent.as_str()).is_none_or(BTreeMap::is_empty))
|
||||
{
|
||||
second.extend(['~', ' ']);
|
||||
}
|
||||
result.insert(
|
||||
id.to_owned(),
|
||||
[first.into_iter().collect(), second.into_iter().collect()],
|
||||
);
|
||||
lanes = next;
|
||||
}
|
||||
self.picker_graph = result;
|
||||
}
|
||||
|
||||
fn refilter(&mut self) {
|
||||
self.visible.clear();
|
||||
self.search_matches.clear();
|
||||
@@ -1619,7 +1832,7 @@ impl DiffState {
|
||||
self.search_matches.push((score, index));
|
||||
}
|
||||
}
|
||||
if self.pull_request_remote.is_none() {
|
||||
if self.pull_request_remote.is_none() && catalog.kind == RepoKind::Git {
|
||||
self.search_matches
|
||||
.sort_unstable_by_key(|&(score, index)| (score, index));
|
||||
}
|
||||
@@ -1663,8 +1876,34 @@ impl DiffState {
|
||||
b_number.cmp(&a_number)
|
||||
});
|
||||
}
|
||||
if self.pull_request_remote.is_none() {
|
||||
if let Some(before) = &self.before {
|
||||
let same = |entry: &Revision| {
|
||||
entry.target == before.target
|
||||
|| self
|
||||
.graph_revision(entry)
|
||||
.zip(self.graph_revision(before))
|
||||
.is_some_and(|(a, b)| a.0 == b.0)
|
||||
};
|
||||
if !self
|
||||
.visible
|
||||
.iter()
|
||||
.any(|&index| same(&catalog.entries[index]))
|
||||
&& let Some(index) = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.target == before.target)
|
||||
{
|
||||
self.visible.push(index);
|
||||
}
|
||||
}
|
||||
if catalog.kind == RepoKind::Jujutsu {
|
||||
self.visible.sort_unstable();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.selected = 0;
|
||||
self.rebuild_picker_graph();
|
||||
}
|
||||
|
||||
fn select_target(&mut self, target: &RevisionTarget) {
|
||||
@@ -1838,7 +2077,7 @@ impl DiffState {
|
||||
|
||||
fn choose(&mut self) {
|
||||
let revision = self.entries().nth(self.selected).cloned();
|
||||
let Some(revision) = revision else {
|
||||
let Some(mut revision) = revision else {
|
||||
self.retry_pull_requests();
|
||||
// Listing can be retried without closing the modal.
|
||||
if self.catalog.is_none()
|
||||
@@ -1861,6 +2100,12 @@ impl DiffState {
|
||||
}
|
||||
if revision.target == RevisionTarget::Working {
|
||||
self.comparison_mode = ComparisonMode::Snapshots;
|
||||
revision.label = if self.phase == Phase::After && self.follow_working_copy {
|
||||
"Follow checkout"
|
||||
} else {
|
||||
"Checkout snapshot"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
self.queue(Operation::FetchRemote(remote.clone()));
|
||||
@@ -1891,7 +2136,7 @@ impl DiffState {
|
||||
return Err("Select a pull request before changing comparison mode");
|
||||
}
|
||||
if self.working_selected() {
|
||||
return Err("Working checkout requires Snapshots");
|
||||
return Err("Checkout selections require Snapshots");
|
||||
}
|
||||
self.comparison_mode = match self.comparison_mode {
|
||||
ComparisonMode::BranchChanges => ComparisonMode::Snapshots,
|
||||
|
||||
@@ -2282,6 +2282,7 @@ pub struct Editor {
|
||||
diff_return_buffer: Option<usize>,
|
||||
diff_context_options: (usize, usize),
|
||||
diff_display_options: (ViewMode, SplitAxis),
|
||||
pub(crate) diff_follow_working_copy: bool,
|
||||
pub(crate) pull_request_review_requested_symbol: String,
|
||||
pub(crate) pull_request_approved_symbol: String,
|
||||
pub(crate) pull_request_preferences: crate::pull_requests::Preferences,
|
||||
@@ -2429,6 +2430,7 @@ impl Editor {
|
||||
diff_return_buffer: None,
|
||||
diff_context_options: (10, 15),
|
||||
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
|
||||
diff_follow_working_copy: false,
|
||||
pull_request_review_requested_symbol: "🙏".into(),
|
||||
pull_request_approved_symbol: "👍".into(),
|
||||
pull_request_preferences: Default::default(),
|
||||
@@ -4764,6 +4766,7 @@ impl Editor {
|
||||
diff.word_wrap = self.active.view.word_wrap;
|
||||
diff.view_mode = self.diff_display_options.0;
|
||||
diff.split_axis = self.diff_display_options.1;
|
||||
diff.follow_working_copy = self.diff_follow_working_copy;
|
||||
diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
|
||||
self.diff = Some(diff);
|
||||
}
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ Restart: :restart closes clean buffers; unsaved buffers remain open and block re
|
||||
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
|
||||
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
|
||||
Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||
Diff: :diff (Normal); floating jj log graph; choose Before then After; selected Before is marked inline
|
||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||
All text panes use native motions, selections and copy; read-only panes reject edits/saves
|
||||
Switching Before/After preserves the aligned cursor row and display column
|
||||
@@ -93,7 +93,7 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
|
||||
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
|
||||
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
|
||||
Follow checkout After edits live buffers and follows descendants; config.diff_follow_working_copy = false disables following
|
||||
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
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
|
||||
+108
-12
@@ -1,7 +1,8 @@
|
||||
//! Read-only active-repository discovery and isolated revision comparison.
|
||||
use crate::diff::{
|
||||
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
|
||||
PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
|
||||
PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionGraph, RevisionTarget,
|
||||
line_hunks,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -1180,10 +1181,25 @@ impl Repository {
|
||||
root: &Path,
|
||||
store: &Path,
|
||||
entries: &mut Vec<Revision>,
|
||||
graph: &mut BTreeMap<String, RevisionGraph>,
|
||||
notices: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
if self.kind == RepoKind::Jujutsu {
|
||||
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "]\n""#;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct JjRevision(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
String,
|
||||
String,
|
||||
bool,
|
||||
bool,
|
||||
);
|
||||
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "," ++ json(parents.map(|p| p.commit_id())) ++ "," ++ json(author.name()) ++ "," ++ json(author.timestamp().format("%Y-%m-%d %H:%M")) ++ "," ++ json(current_working_copy) ++ "," ++ json(immutable) ++ "]\n""#;
|
||||
let revset = if root == self.root {
|
||||
"all()"
|
||||
} else {
|
||||
@@ -1192,15 +1208,40 @@ impl Repository {
|
||||
let output =
|
||||
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
|
||||
for line in output.lines() {
|
||||
let (id, change, description, bookmarks, remote_names, remotes): (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
let JjRevision(
|
||||
id,
|
||||
change,
|
||||
description,
|
||||
bookmarks,
|
||||
remote_names,
|
||||
remotes,
|
||||
parents,
|
||||
author,
|
||||
timestamp,
|
||||
current,
|
||||
immutable,
|
||||
) = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
|
||||
graph.entry(id.clone()).or_insert_with(|| RevisionGraph {
|
||||
change_id: change.clone(),
|
||||
description: description.trim_end_matches('\n').into(),
|
||||
parents,
|
||||
author,
|
||||
timestamp,
|
||||
bookmarks: bookmarks
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
remote_names
|
||||
.iter()
|
||||
.zip(&remotes)
|
||||
.map(|(name, remote)| format!("{name}@{remote}")),
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
current: current && root == self.root,
|
||||
immutable,
|
||||
});
|
||||
let target = RevisionTarget::Commit(id.clone());
|
||||
entries.push(Revision {
|
||||
target: target.clone(),
|
||||
@@ -1370,15 +1411,69 @@ impl Repository {
|
||||
};
|
||||
let mut entries = vec![Revision {
|
||||
target: RevisionTarget::Working,
|
||||
label: "Working checkout".into(),
|
||||
label: "Checkout snapshot".into(),
|
||||
description:
|
||||
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
|
||||
category,
|
||||
}];
|
||||
let mut notices = vec![];
|
||||
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?;
|
||||
let mut graph = BTreeMap::new();
|
||||
self.revisions(
|
||||
&self.root,
|
||||
&self.store,
|
||||
&mut entries,
|
||||
&mut graph,
|
||||
&mut notices,
|
||||
)?;
|
||||
if let Some((root, store)) = &self.private {
|
||||
self.revisions(root, store, &mut entries, &mut notices)?;
|
||||
self.revisions(root, store, &mut entries, &mut graph, &mut notices)?;
|
||||
}
|
||||
if self.kind == RepoKind::Jujutsu && self.private.is_some() {
|
||||
let order: BTreeMap<_, _> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, entry)| match &entry.target {
|
||||
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut children: BTreeMap<_, usize> =
|
||||
graph.keys().map(|id| (id.as_str(), 0)).collect();
|
||||
for node in graph.values() {
|
||||
for parent in &node.parents {
|
||||
if let Some(count) = children.get_mut(parent.as_str()) {
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ready: BTreeSet<_> = children
|
||||
.iter()
|
||||
.filter(|(_, count)| **count == 0)
|
||||
.map(|(&id, _)| (order[id], id))
|
||||
.collect();
|
||||
let mut ranks = BTreeMap::new();
|
||||
while let Some((_, id)) = ready.pop_first() {
|
||||
ranks.insert(id, ranks.len() + 1);
|
||||
for parent in &graph[id].parents {
|
||||
if let Some(count) = children.get_mut(parent.as_str()) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
ready.insert((order[parent.as_str()], parent.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by_key(|entry| match &entry.target {
|
||||
RevisionTarget::Working => 0,
|
||||
RevisionTarget::Commit(id) => ranks.get(id.as_str()).copied().unwrap_or(usize::MAX),
|
||||
RevisionTarget::Bookmark { .. } => entry
|
||||
.description
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|id| ranks.get(id).copied())
|
||||
.unwrap_or(usize::MAX),
|
||||
_ => usize::MAX,
|
||||
});
|
||||
}
|
||||
for remote in &self.remotes {
|
||||
for &view in self.kind.categories() {
|
||||
@@ -1439,6 +1534,7 @@ impl Repository {
|
||||
Ok(Catalog {
|
||||
kind: self.kind,
|
||||
entries,
|
||||
graph,
|
||||
notice: if notices.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -580,6 +580,7 @@ impl EditorSession {
|
||||
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
|
||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||
editor.diff_follow_working_copy = config.diff_follow_working_copy;
|
||||
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
|
||||
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
|
||||
editor.resolve_history_layout = config.resolve_history_layout;
|
||||
|
||||
+95
-12
@@ -3588,6 +3588,8 @@ fn draw_diff(
|
||||
}
|
||||
} else if pull_requests {
|
||||
" Pull requests ".into()
|
||||
} else if panel.width < 50 {
|
||||
format!(" Choose {stage} ")
|
||||
} else {
|
||||
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
|
||||
};
|
||||
@@ -3700,10 +3702,6 @@ fn draw_diff(
|
||||
Constraint::Length(u16::from(content.height >= 7)),
|
||||
])
|
||||
.areas(content);
|
||||
let before = diff
|
||||
.before
|
||||
.as_ref()
|
||||
.map_or("not selected", |revision| revision.label.as_str());
|
||||
let stage_text = if pull_requests {
|
||||
let filters = &diff.pull_request_preferences.filters;
|
||||
let review = if filters.len() == 3 {
|
||||
@@ -3735,11 +3733,7 @@ fn draw_diff(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} · Ctrl+b toggle · Before: {}",
|
||||
diff.comparison_mode.label(),
|
||||
display_text(before)
|
||||
)
|
||||
format!("{} · Ctrl+b toggle", diff.comparison_mode.label())
|
||||
};
|
||||
frame.render_widget(Paragraph::new(stage_text), stage_area);
|
||||
if pull_requests || diff.catalog.is_some() {
|
||||
@@ -3803,11 +3797,34 @@ fn draw_diff(
|
||||
);
|
||||
}
|
||||
let mut entries = diff.entries();
|
||||
let before_matches = |revision: &crate::diff::Revision| {
|
||||
diff.before.as_ref().is_some_and(|before| {
|
||||
match (diff.graph_revision(before), diff.graph_revision(revision)) {
|
||||
(Some((before, _)), Some((candidate, _))) => before == candidate,
|
||||
_ => before.target == revision.target,
|
||||
}
|
||||
})
|
||||
};
|
||||
if !pull_requests && let Some(working) = entries.next() {
|
||||
let follow = diff.phase == Phase::After && diff.follow_working_copy;
|
||||
let label = format!(
|
||||
"{} {} · pinned",
|
||||
"{} {}{} · pinned · {}",
|
||||
if diff.selected == 0 { ">" } else { " " },
|
||||
display_text(&working.label)
|
||||
if follow {
|
||||
"Follow checkout"
|
||||
} else {
|
||||
"Checkout snapshot"
|
||||
},
|
||||
if before_matches(working) {
|
||||
" [Before]"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
if follow {
|
||||
"Live files; follows descendant checkouts"
|
||||
} else {
|
||||
"Live files at this checkout"
|
||||
},
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style(
|
||||
@@ -3863,6 +3880,7 @@ fn draw_diff(
|
||||
0
|
||||
};
|
||||
let decoration_width = symbol_width + usize::from(symbol_width > 0);
|
||||
let graph = diff.picker_graph();
|
||||
for (index, revision) in entries
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
@@ -3876,6 +3894,71 @@ fn draw_diff(
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let before_badge = if before_matches(revision) {
|
||||
" [Before]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if let Some((id, metadata)) = diff.graph_revision(revision)
|
||||
&& let Some(prefix) = graph.get(id)
|
||||
{
|
||||
let width = usize::from(results.width);
|
||||
let change = metadata.change_id.as_str();
|
||||
let change = change.get(..12).unwrap_or(change);
|
||||
let bookmark = if revision.category == crate::diff::Category::Bookmarks {
|
||||
format!("[{}] ", display_text(&revision.label))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let label = format!(
|
||||
"{} {}{bookmark}{}{} {} {} {} {}",
|
||||
if index == diff.selected { ">" } else { " " },
|
||||
prefix[0],
|
||||
display_text(change),
|
||||
before_badge,
|
||||
display_text(&metadata.author),
|
||||
display_text(&metadata.timestamp),
|
||||
if metadata.bookmarks.is_empty() || !bookmark.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("[{}]", display_text(&metadata.bookmarks))
|
||||
},
|
||||
&id[..id.len().min(12)],
|
||||
);
|
||||
if row_height == 2 {
|
||||
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
||||
lines.push(Line::styled(
|
||||
visible_line(
|
||||
&format!(" {}{}", prefix[1], display_text(&metadata.description)),
|
||||
0,
|
||||
width,
|
||||
),
|
||||
style.fg(Color::DarkGray),
|
||||
));
|
||||
} else {
|
||||
lines.push(Line::styled(
|
||||
visible_line(
|
||||
&format!(
|
||||
"{} {}{}{}{bookmark}{} · {}",
|
||||
if index == diff.selected { ">" } else { " " },
|
||||
prefix[0],
|
||||
if prefix[1].contains('~') { "~ " } else { "" },
|
||||
if before_badge.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"[Before] "
|
||||
},
|
||||
display_text(change),
|
||||
display_text(&metadata.description),
|
||||
),
|
||||
0,
|
||||
width,
|
||||
),
|
||||
style,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let symbol = if pull_requests {
|
||||
match diff.pull_request_review_state(revision) {
|
||||
crate::diff::PullRequestReviewState::Requested => requested_symbol,
|
||||
@@ -3886,7 +3969,7 @@ fn draw_diff(
|
||||
""
|
||||
};
|
||||
let label = format!(
|
||||
"{} {symbol}{:padding$}{}",
|
||||
"{} {symbol}{:padding$}{}{before_badge}",
|
||||
if index == diff.selected { ">" } else { " " },
|
||||
"",
|
||||
display_text(&revision.label),
|
||||
|
||||
@@ -629,10 +629,6 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::Snapshots
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().message(),
|
||||
"Working checkout requires Snapshots"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1367,6 +1367,31 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
);
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
||||
let loaded = repository.fetch_remote("origin").unwrap();
|
||||
assert_eq!(loaded.graph[&tip].parents, [ancestor.clone()]);
|
||||
assert_eq!(
|
||||
loaded.graph[&ancestor].description,
|
||||
"remote historic needle"
|
||||
);
|
||||
assert!(!loaded.graph[&tip].current);
|
||||
let positions: std::collections::BTreeMap<_, _> = loaded
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.category == Category::Revisions)
|
||||
.enumerate()
|
||||
.filter_map(|(index, entry)| match &entry.target {
|
||||
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (id, metadata) in &loaded.graph {
|
||||
for parent in &metadata.parents {
|
||||
if let (Some(child), Some(parent)) =
|
||||
(positions.get(id.as_str()), positions.get(parent.as_str()))
|
||||
{
|
||||
assert!(child < parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
let historic = loaded
|
||||
.entries
|
||||
.iter()
|
||||
|
||||
+176
-4
@@ -394,6 +394,7 @@ fn revision(
|
||||
fn catalog() -> Catalog {
|
||||
Catalog {
|
||||
kind: RepoKind::Git,
|
||||
graph: Default::default(),
|
||||
entries: vec![
|
||||
revision(
|
||||
RevisionTarget::Working,
|
||||
@@ -1573,7 +1574,7 @@ fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
||||
fn jujutsu_fuzzy_selectors_preserve_catalog_order_in_both_stages() {
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() {
|
||||
let mut state = DiffState::new();
|
||||
@@ -1581,6 +1582,7 @@ fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
||||
1,
|
||||
Ok(Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Jujutsu,
|
||||
graph: Default::default(),
|
||||
entries: vec![
|
||||
revision(
|
||||
RevisionTarget::Working,
|
||||
@@ -1621,15 +1623,185 @@ fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
||||
.map(|entry| &entry.target)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
&RevisionTarget::Commit("immutable-exact-target".into()),
|
||||
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
||||
&RevisionTarget::Commit("description-target".into()),
|
||||
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
||||
&RevisionTarget::Commit("immutable-exact-target".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn graph_state() -> DiffState {
|
||||
let mut catalog = Catalog {
|
||||
kind: RepoKind::Jujutsu,
|
||||
graph: Default::default(),
|
||||
notice: None,
|
||||
entries: vec![revision(
|
||||
RevisionTarget::Working,
|
||||
"Working",
|
||||
"",
|
||||
Category::Revisions,
|
||||
)],
|
||||
};
|
||||
for (id, parents) in [
|
||||
("merge", vec!["left", "right"]),
|
||||
("left", vec!["hidden"]),
|
||||
("hidden", vec!["root"]),
|
||||
("right", vec!["root"]),
|
||||
("root", vec![]),
|
||||
] {
|
||||
catalog.entries.push(revision(
|
||||
RevisionTarget::Commit(id.into()),
|
||||
id,
|
||||
"needle",
|
||||
Category::Revisions,
|
||||
));
|
||||
catalog.graph.insert(
|
||||
id.into(),
|
||||
RevisionGraph {
|
||||
parents: parents.into_iter().map(String::from).collect(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
catalog.entries.push(revision(
|
||||
RevisionTarget::Bookmark {
|
||||
name: "topic".into(),
|
||||
remote: None,
|
||||
},
|
||||
"topic",
|
||||
"change merge needle",
|
||||
Category::Bookmarks,
|
||||
));
|
||||
catalog.entries.push(revision(
|
||||
RevisionTarget::Bookmark {
|
||||
name: "base".into(),
|
||||
remote: None,
|
||||
},
|
||||
"base",
|
||||
"change root needle",
|
||||
Category::Bookmarks,
|
||||
));
|
||||
let mut state = DiffState::new();
|
||||
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_graph_preserves_forks_merges_and_marks_filtered_ancestry() {
|
||||
let mut state = graph_state();
|
||||
let graph = state.picker_graph();
|
||||
assert_eq!(graph.len(), 5);
|
||||
assert_eq!(graph["merge"][1].trim_end(), "├─┐");
|
||||
assert_eq!(graph["right"][1].trim_end(), "├─┘");
|
||||
assert!(!graph.values().flatten().any(|prefix| prefix.contains('~')));
|
||||
state.category = 1;
|
||||
state.refilter();
|
||||
assert_eq!(state.entries().len(), 3);
|
||||
let graph = state.picker_graph();
|
||||
assert_eq!(
|
||||
graph.keys().map(String::as_str).collect::<Vec<_>>(),
|
||||
["merge", "root"]
|
||||
);
|
||||
assert!(graph["merge"][1].contains('~'));
|
||||
assert_eq!(
|
||||
state
|
||||
.graph_revision(state.entries().nth(1).unwrap())
|
||||
.unwrap()
|
||||
.0,
|
||||
"merge"
|
||||
);
|
||||
assert!(matches!(
|
||||
state.entries().nth(1).unwrap().target,
|
||||
RevisionTarget::Bookmark { .. }
|
||||
));
|
||||
state.category = 0;
|
||||
state.query = "left root".into();
|
||||
state.refilter();
|
||||
assert!(state.picker_graph().is_empty());
|
||||
assert_eq!(state.entries().len(), 1);
|
||||
state.before = Some(state.catalog.as_ref().unwrap().entries[1].clone());
|
||||
state.query = "right".into();
|
||||
state.refilter();
|
||||
assert_eq!(state.picker_graph().len(), 2);
|
||||
assert!(state.picker_graph()["merge"][1].contains('~'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_crossings_do_not_join_unrelated_ancestry() {
|
||||
let mut state = DiffState::new();
|
||||
let mut catalog = Catalog {
|
||||
kind: RepoKind::Jujutsu,
|
||||
entries: Vec::new(),
|
||||
graph: Default::default(),
|
||||
notice: None,
|
||||
};
|
||||
for (id, parents) in [
|
||||
("a", vec!["x"]),
|
||||
("b", vec!["y"]),
|
||||
("merge", vec!["x", "z"]),
|
||||
("x", vec![]),
|
||||
("y", vec![]),
|
||||
("z", vec![]),
|
||||
] {
|
||||
catalog.entries.push(revision(
|
||||
RevisionTarget::Commit(id.into()),
|
||||
id,
|
||||
"",
|
||||
Category::Revisions,
|
||||
));
|
||||
catalog.graph.insert(
|
||||
id.into(),
|
||||
RevisionGraph {
|
||||
parents: parents.into_iter().map(String::from).collect(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
|
||||
assert_eq!(state.picker_graph()["merge"][1].trim_end(), "├─╪─┤");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_identity_survives_query_and_category_without_duplicate_graph_nodes() {
|
||||
let mut state = graph_state();
|
||||
state.category = 1;
|
||||
state.refilter();
|
||||
state.selected = 1;
|
||||
state.choose();
|
||||
let before = state.before.clone().unwrap();
|
||||
assert!(matches!(before.target, RevisionTarget::Bookmark { .. }));
|
||||
state.category = 0;
|
||||
state.refilter();
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.filter(|entry| state
|
||||
.graph_revision(entry)
|
||||
.is_some_and(|(id, _)| id == "merge"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
for category in 0..2 {
|
||||
state.category = category;
|
||||
state.query = "unmatched".into();
|
||||
state.refilter();
|
||||
assert_eq!(state.entries().len(), 2);
|
||||
let retained = state
|
||||
.entries()
|
||||
.find(|entry| entry.target == before.target)
|
||||
.unwrap();
|
||||
assert_eq!(retained.target, before.target);
|
||||
assert_eq!(state.before.as_ref(), Some(&before));
|
||||
state.selected = 1;
|
||||
assert_eq!(
|
||||
state.entries().nth(state.selected).unwrap().target,
|
||||
before.target
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
|
||||
let mut editor = editor();
|
||||
@@ -1746,7 +1918,7 @@ fn loader_preserves_after_step_query_category_and_selection_then_compares() {
|
||||
state.entries().nth(state.selected).unwrap().target,
|
||||
RevisionTarget::FetchRemote("origin".into())
|
||||
);
|
||||
assert_eq!(state.entries().len(), 3);
|
||||
assert_eq!(state.entries().len(), 4);
|
||||
let remote_position = state
|
||||
.entries()
|
||||
.position(
|
||||
|
||||
@@ -1836,6 +1836,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
|
||||
let catalog = || {
|
||||
Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
graph: Default::default(),
|
||||
entries: [
|
||||
RevisionTarget::Commit("base".into()),
|
||||
RevisionTarget::Working,
|
||||
@@ -2627,6 +2628,7 @@ fn diff_results_bind_the_owning_pane_without_disturbing_focused_document_or_mode
|
||||
id,
|
||||
Ok(Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
graph: Default::default(),
|
||||
entries: vec![Revision {
|
||||
target: RevisionTarget::Working,
|
||||
label: "Working".into(),
|
||||
@@ -3039,6 +3041,7 @@ fn working_diff_with_revisions(
|
||||
id,
|
||||
Ok(Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
graph: Default::default(),
|
||||
entries: vec![
|
||||
revision(RevisionTarget::Commit("base".into()), "base"),
|
||||
revision(RevisionTarget::Working, "Working"),
|
||||
@@ -3113,6 +3116,7 @@ fn historical_diff_with_revisions(
|
||||
id,
|
||||
Ok(Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
graph: Default::default(),
|
||||
entries: vec![revision("base"), revision("head")],
|
||||
notice: None,
|
||||
})),
|
||||
|
||||
@@ -463,6 +463,80 @@ fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_catalog_exposes_fixed_metadata_and_merge_parents_without_snapshotting() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let root = &temp.0;
|
||||
fixture_command(root, "jj", &["git", "init", "--no-colocate", "."]);
|
||||
fixture_command(root, "jj", &["describe", "-m", "Base"]);
|
||||
let id = |root: &Path| {
|
||||
fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
)
|
||||
};
|
||||
let base = id(root);
|
||||
fixture_command(root, "jj", &["bookmark", "create", "base"]);
|
||||
fixture_command(root, "jj", &["new", &base, "-m", "Left"]);
|
||||
let left = id(root);
|
||||
fixture_command(root, "jj", &["new", &base, "-m", "Right"]);
|
||||
let right = id(root);
|
||||
fixture_command(root, "jj", &["new", &left, &right, "-m", "Merge"]);
|
||||
let current = id(root);
|
||||
let operation = fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let mut repository = Repository::open(root, Arc::new(AtomicBool::new(false))).unwrap();
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let merge = &catalog.graph[¤t];
|
||||
assert_eq!(merge.parents, [left, right]);
|
||||
assert_eq!(merge.author, "Picker Author");
|
||||
assert_eq!(merge.timestamp, "2001-01-01 00:00");
|
||||
assert_eq!(merge.description, "Merge");
|
||||
assert!(!merge.change_id.is_empty());
|
||||
assert!(merge.current);
|
||||
assert_eq!(catalog.graph[&base].bookmarks, "base");
|
||||
assert!(!catalog.graph[&base].current);
|
||||
assert!(catalog.graph[&"0".repeat(40)].immutable);
|
||||
assert_eq!(
|
||||
fixture_command(
|
||||
root,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id"
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
|
||||
@@ -556,6 +556,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
|
||||
id,
|
||||
Ok(DiffOutcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
graph: Default::default(),
|
||||
notice: None,
|
||||
entries: [
|
||||
RevisionTarget::Working,
|
||||
@@ -3725,6 +3726,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
|
||||
id,
|
||||
Ok(DiffOutcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
graph: Default::default(),
|
||||
notice: None,
|
||||
entries: vec![
|
||||
revision(RevisionTarget::Commit("base".into()), "base"),
|
||||
|
||||
+129
-5
@@ -1951,6 +1951,7 @@ fn diff_catalog() -> crate::diff::Catalog {
|
||||
Catalog {
|
||||
kind: RepoKind::Git,
|
||||
entries,
|
||||
graph: Default::default(),
|
||||
notice: None,
|
||||
}
|
||||
}
|
||||
@@ -3501,7 +3502,7 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
|
||||
assert!(text.contains("Choose Before"));
|
||||
assert!(text.contains("Tab categories"));
|
||||
assert!(text.contains("Enter select"));
|
||||
assert!(text.contains("Working checkout · pinned"));
|
||||
assert!(text.contains("Checkout snapshot · pinned"));
|
||||
assert!(text.contains("> commit-19"));
|
||||
assert!(!text.contains("commit-00"));
|
||||
for ch in "needle".chars() {
|
||||
@@ -3511,13 +3512,14 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> needle"));
|
||||
assert!(text.contains("Working checkout · pinned"));
|
||||
assert!(text.contains("Checkout snapshot · pinned"));
|
||||
assert!(text.contains("> commit-19"));
|
||||
diff.handle(Input::Enter);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Choose After"));
|
||||
assert!(text.contains("Branch changes · Ctrl+b toggle · Before: commit-19"));
|
||||
assert!(text.contains("Branch changes · Ctrl+b toggle"));
|
||||
assert!(!text.contains("Before:"));
|
||||
assert!(screen_text(&terminal).contains("Tab categories"));
|
||||
assert!(screen_text(&terminal).contains("Enter select"));
|
||||
diff.handle(Input::Ctrl('u'));
|
||||
@@ -3525,8 +3527,8 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("feature-branch"));
|
||||
assert!(!text.contains("ordinary change"));
|
||||
assert!(text.contains("Working checkout · pinned"));
|
||||
assert!(text.contains("commit-19 [Before]"));
|
||||
assert!(text.contains("Checkout snapshot · pinned"));
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert!(
|
||||
buffer
|
||||
@@ -3536,6 +3538,128 @@ fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_picker_labels_follow_the_configured_stage() {
|
||||
let mut diff = DiffState::new();
|
||||
diff.follow_working_copy = true;
|
||||
diff.take_request();
|
||||
diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(
|
||||
screen_text(&terminal).contains("Checkout snapshot · pinned · Live files at this checkout")
|
||||
);
|
||||
diff.handle(Input::Enter);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Follow checkout [Before] · pinned"));
|
||||
assert!(screen_text(&terminal).contains("follows descendant checkouts"));
|
||||
diff.follow_working_copy = false;
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Checkout snapshot [Before] · pinned"));
|
||||
assert!(!screen_text(&terminal).contains("Follow checkout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_picker_renders_graph_before_badge_and_compacts_without_losing_controls() {
|
||||
use crate::diff::{Category, RepoKind, Revision, RevisionGraph, RevisionTarget};
|
||||
let mut catalog = diff_catalog();
|
||||
catalog.kind = RepoKind::Jujutsu;
|
||||
catalog.entries.truncate(1);
|
||||
for (id, change, parent, current, immutable) in [
|
||||
("aaaa1111", "change-a", Some("bbbb2222"), true, false),
|
||||
("bbbb2222", "change-b", None, false, true),
|
||||
] {
|
||||
catalog.entries.push(Revision {
|
||||
target: RevisionTarget::Commit(id.into()),
|
||||
label: change.into(),
|
||||
description: format!("{change} description"),
|
||||
category: Category::Revisions,
|
||||
});
|
||||
catalog.graph.insert(
|
||||
id.into(),
|
||||
RevisionGraph {
|
||||
change_id: change.into(),
|
||||
description: format!("{change} description"),
|
||||
parents: parent.into_iter().map(str::to_owned).collect(),
|
||||
author: "alice".into(),
|
||||
timestamp: "2026-10-23".into(),
|
||||
bookmarks: if current {
|
||||
"main".into()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
current,
|
||||
immutable,
|
||||
},
|
||||
);
|
||||
}
|
||||
for name in ["main", "topic"] {
|
||||
catalog.entries.push(Revision {
|
||||
target: RevisionTarget::Bookmark {
|
||||
name: name.into(),
|
||||
remote: None,
|
||||
},
|
||||
label: name.into(),
|
||||
description: "change-a aaaa1111 change-a description".into(),
|
||||
category: Category::Bookmarks,
|
||||
});
|
||||
}
|
||||
let mut diff = DiffState::new();
|
||||
diff.take_request();
|
||||
diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(catalog)));
|
||||
diff.handle(Input::Down);
|
||||
diff.handle(Input::Enter);
|
||||
diff.handle(Input::Down);
|
||||
diff.handle(Input::Down);
|
||||
let mut panels = WheelPanels::default();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Choose After"));
|
||||
assert!(text.contains("@"));
|
||||
assert!(text.contains("change-a [Before] alice 2026-10-23 [main] aaaa1111"));
|
||||
assert!(text.contains("change-a description"));
|
||||
assert!(!text.contains("Before:"));
|
||||
let buffer = terminal.backend().buffer();
|
||||
let selected_rows: Vec<_> = (0..buffer.area.height)
|
||||
.filter(|&y| (0..buffer.area.width).any(|x| buffer[(x, y)].bg == Color::Blue))
|
||||
.collect();
|
||||
assert_eq!(selected_rows.len(), 2);
|
||||
assert_eq!(selected_rows[1], selected_rows[0] + 1);
|
||||
let before_row = (0..buffer.area.height)
|
||||
.find(|&y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
.contains("[Before]")
|
||||
})
|
||||
.unwrap();
|
||||
assert!((0..buffer.area.width).all(|x| buffer[(x, before_row)].bg != Color::Blue));
|
||||
for (width, height) in [(44, 12), (20, 8)] {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Choose After"));
|
||||
assert!(text.contains("[Before]"));
|
||||
assert!(text.contains("> "));
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(
|
||||
(0..height)
|
||||
.filter(|&y| { (0..width).any(|x| buffer[(x, y)].bg == Color::Blue) })
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
diff.handle(Input::Char('\t'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("[main] change-a"));
|
||||
assert!(text.contains("[topic] change-a"));
|
||||
assert_eq!(text.matches("change-a description").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
|
||||
use crate::diff::{Category, PullRequestPage, RepoKind, Revision, RevisionTarget};
|
||||
|
||||
Reference in New Issue
Block a user