Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agents/format-projections.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ SwiftUI also offers an inline HTML preview button (same on-disk-file requirement
5. Projection failures should degrade to raw content or a binary placeholder with diagnostics. They should not make the whole file diff unusable.
6. Switching raw/processed for the same file should keep the current rendered diff visible until replacement content is ready. Switching to a different file resets rich-preview state so one file's processed view does not become another file's default.
7. Keep v1 static and narrow: no dynamic plugin ABI, no projected diff editing, and no virtual file tree formats such as XLSX/DOCX/ZIP until the product behavior is designed.
8. Per-file diff stats count the effective display mode: processed for formats that open processed by default (binary plists), raw otherwise.
4 changes: 2 additions & 2 deletions agents/shell-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Update this matrix when the user guide adds a feature, a shell closes a gap, or
| Rich File Previews | Yes | Partial — Markdown images; inline HTML | Raw/processed projection modes and cache identity match. GPUI has projection controls, banners, HTML external open, native SVG preview, and a rendered Markdown preview (native block renderer, single post-change document with scrolling — same single-view model as SwiftUI). Exact gaps: Markdown image blocks render as placeholders instead of actual images; GPUI has no inline sandboxed HTML preview toggle, only external-open. |
| Review Notes | Yes | Yes | GPUI supports add/edit/resolve/delete review notes, gutter dot markers, inline note rows, file-list badges, the noted-files filter, and a stale/orphaned banner. Inline note rendering is unified-view-only in both shells; side-by-side shows a note-count banner with "Show in Unified" in both, so that is not a GPUI gap. |
| Compare Changes | Yes | Yes | Shift-click compare, bookmark diff, reverse compare, clear compare, and interdiff loading should use the same rev semantics. |
| Edit Diffs & Split Work | Yes | Yes | Both shells cover line-granularity discard from the normal diff gutter and a dedicated multi-file Diff Edit view with per-file cards, line/hunk/file selection, select-all, keep-only-selected Done, Move to Working Copy, New Child, and New Parallel. |
| Edit Diffs & Split Work | Yes | Yes | Both shells cover line-granularity discard from the normal diff gutter and a dedicated multi-file Diff Edit view with per-file cards, line/hunk/file selection, select-all, keep-only-selected Done, Move to Working Copy, New Child, and New Parallel. Both show per-file +/− line stats in card headers, per-file collapse/expand, Expand All / Collapse All buttons with `mod+alt+e` / `mod+alt+c`, auto-collapse for large diffs via the shared `diff_edit_auto_collapsed_paths` policy, and a keyboard focus model: `j`/`k` or `↑`/`↓` move between file cards, `←`/`→` collapse/expand, `Return` toggles, and `Space` selects the focused card. |
| Change Operations | Yes | Partial — DAG rewrites; @ description prefill | GPUI covers new-change-on-top, abandon, operation-log restore, editing descriptions (including `jj describe` on @), committing, codex/claude AI messages, file multi-select split/commit and batch actions, and Diff Edit destinations including Move to Working Copy, New Child, and New Parallel. Exact gaps: no DAG actions for `jj edit`, squash into parent/selected, rebase selected, merge selected, duplicate, absorb, or revert/backout; the commit box also does not initially prefill from @'s existing description. |
| Bookmarks, Git & Pull Requests | Yes | Partial — bookmark-manager mutations; post-drag Push | GPUI covers bookmark creation, DAG drag/move, tracking, push/delete, Git fetch/push, remote URL opening, GitHub/GitLab/Codeberg PR/MR opening, and selected-bookmark PR/MR plus CI status. Exact gaps: the Bookmark Manager does not expose rename, push, delete, PR/MR open, conflict resolution, per-remote ahead/behind/diverged details, remote choice when tracking, or the hide-by-default **Show deleted** plus per-row **Forget** flow; dragging a tracked bookmark also does not show SwiftUI's one-click sidebar **Push** affordance. |
| Workspaces | Yes | Yes | Both shells create a workspace by name into a sibling directory, open it in its own window, show workspace context, and forget workspaces. GPUI entry points: Repository menu, status-bar workspace picker, and palette; switching lives in the status-bar picker where SwiftUI also offers palette "Switch to" entries — a presentation difference, not a workflow gap. |
Expand All @@ -36,7 +36,7 @@ Update this matrix when the user guide adds a feature, a shell closes a gap, or
| Command Palette | Yes | Yes | Action names, raw jj behavior, command output handling, and searchable help topics should stay aligned even if presentation differs. |
| Tools & Settings | Yes | Partial — custom command editing; font-size setting | Shared config ids and option vocabularies match. Exact gaps: GPUI displays but cannot edit custom editor/terminal command values in Settings, and font size is changed only through zoom commands rather than the Settings control. GPUI includes codex/claude and jj/gh/glab detection plus a Linux `jayjay` installer (AppImage-aware symlink in `~/.local/bin`). SwiftUI-only macOS integrations are outside the GPUI Linux parity target. |
| Help & User Guide Access | Yes | Yes | Both shells reach the same help content and expose Send Feedback from the Help menu and command palette. On Linux, GPUI uses the desktop's `xdg-open` handler for the guide and pre-addressed feedback email. Its palette lists the same searchable help topics (shared `HelpFeatures.json`) opening the guide at each topic's anchor. The bundled macOS Help Book is SwiftUI-only. |
| Keyboard Shortcuts | Yes | Partial — shortcut reference window | GPUI implements every shortcut published in the user guide: palette, find, refresh, open repository, zoom, Bookmark Manager, operation log/undo, file review, shift-click compare, `j`/`k`, and `Ctrl+N`/`Ctrl+P`. GPUI uses `Ctrl` as its primary modifier on Linux. Exact gap: GPUI does not provide SwiftUI's **Help → Keyboard Shortcuts** reference window. |
| Keyboard Shortcuts | Yes | Partial — shortcut reference window | GPUI implements every shortcut published in the user guide: palette, find, refresh, open repository, zoom, Bookmark Manager, operation log/undo, file review, shift-click compare, diff-edit expand/collapse all plus card navigation (`j`/`k`, arrows, `Return`), `j`/`k`, and `Ctrl+N`/`Ctrl+P`. GPUI uses `Ctrl` as its primary modifier on Linux. Exact gap: GPUI does not provide SwiftUI's **Help → Keyboard Shortcuts** reference window. |
| GPUI Shell Alpha | N/A | Yes | The guide's GPUI alpha section is the source of truth for current GPUI coverage claims. Update it and this matrix together. |

## Parity Rules
Expand Down
31 changes: 31 additions & 0 deletions crates/jayjay-core/src/repo/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ pub(super) struct TreePair {
pub(super) after: MergedTree,
}

fn hunk_line_stats(hunk: &DiffHunk, ignore_whitespace: bool) -> FileDiffStats {
let old = hunk.old.content.as_deref();
let new = hunk.new.content.as_deref();
// The canonical classifier covers every core placeholder (symlink, too-large, ...), unlike jj_diff's narrower display set.
let editable = |text: Option<&str>| text.is_none_or(crate::placeholder::is_editable_text);
let (insertions, deletions) = if editable(old) && editable(new) {
jj_diff::count_changed_lines(
old.unwrap_or_default(),
new.unwrap_or_default(),
ignore_whitespace,
)
} else {
(0, 0)
};
FileDiffStats {
path: hunk.path.clone(),
insertions,
deletions,
}
}

impl Repo {
fn commit_tree_pair(&self, rev: &str) -> CoreResult<TreePair> {
let repo = self.get_repo();
Expand Down Expand Up @@ -215,6 +236,16 @@ impl Repo {
}
}

/// Per-file line counts for the revision, matching what Diff Edit presents: raw (unprojected) text, rename-aware, placeholder-only sides counted as zero.
pub fn diff_file_stats(
&self,
rev: &str,
ignore_whitespace: bool,
) -> CoreResult<Vec<FileDiffStats>> {
let trees = self.commit_tree_pair(rev)?;
self.diff_file_stats_walk(&trees, ignore_whitespace)
}

/// Fast: file list between two arbitrary revisions WITHOUT content.
pub fn interdiff_summary(&self, from_rev: &str, to_rev: &str) -> CoreResult<ChangeDetail> {
let (trees, info) = self.interdiff_trees(from_rev, to_rev)?;
Expand Down
20 changes: 20 additions & 0 deletions crates/jayjay-core/src/repo/diff/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ impl Repo {
Ok(diff)
}

/// Per-file stats over the displayed card list: the content-free walk supplies the cards and their rename pairing, then each card's sides are materialized in its effective display mode and dropped after counting, so no blob outlives its own card.
pub(super) fn diff_file_stats_walk(
&self,
trees: &TreePair,
ignore_whitespace: bool,
) -> CoreResult<Vec<FileDiffStats>> {
let files = self.diff_file_list(trees)?;
let mut stats = Vec::with_capacity(files.len());
for file in files {
let mode = crate::projection::request_mode(file.projection.as_ref(), false)
.unwrap_or(DiffProjectionMode::Raw);
Comment thread
hewigovens marked this conversation as resolved.
let mut hunk = self.diff_single_file_with_mode(trees, &file.path, mode)?;
if let Some(old_path) = file.old_path.as_deref() {
hunk.old = self.diff_single_file_with_mode(trees, old_path, mode)?.old;
}
stats.push(super::hunk_line_stats(&hunk, ignore_whitespace));
}
Ok(stats)
}

/// Materialize a single file between two trees.
pub(super) fn diff_single_file(&self, trees: &TreePair, path: &str) -> CoreResult<DiffHunk> {
self.diff_single_file_with_mode(trees, path, DiffProjectionMode::Processed)
Expand Down
144 changes: 144 additions & 0 deletions crates/jayjay-core/tests/diff_file_stats.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
use std::fs;

use jayjay_core::Repo;
use jj_test::{FormatFixture, init_jj_repo, run_jj_in};

#[test]
fn diff_file_stats_reports_per_file_line_counts() {
let temp_dir = init_jj_repo();
let repo_path = temp_dir.path().join("repo");
run_jj_in(&repo_path, &["new", "-m", "edit files"]);
let repo = Repo::open(&repo_path).expect("open repo");

fs::write(
repo_path.join("hello.txt"),
"hello from tests\nsecond line\n",
)
.expect("modify hello.txt");
fs::write(repo_path.join("added.txt"), "one\ntwo\nthree\n").expect("write added.txt");
repo.refresh_working_copy().expect("snapshot working copy");

let stats = repo.diff_file_stats("@", false).expect("diff file stats");
let for_path = |path: &str| {
stats
.iter()
.find(|file| file.path == path)
.unwrap_or_else(|| panic!("missing stats for {path}"))
};

let added = for_path("added.txt");
assert_eq!((added.insertions, added.deletions), (3, 0));

let modified = for_path("hello.txt");
assert_eq!(
(modified.insertions, modified.deletions),
(2, 1),
"all stats: {stats:?}"
);
}

#[test]
fn diff_file_stats_pairs_same_basename_moves_like_the_card_list() {
let temp_dir = init_jj_repo();
let repo_path = temp_dir.path().join("repo");
run_jj_in(&repo_path, &["new", "-m", "move hello"]);
let repo = Repo::open(&repo_path).expect("open repo");

fs::create_dir(repo_path.join("moved")).expect("create dir");
fs::rename(
repo_path.join("hello.txt"),
repo_path.join("moved/hello.txt"),
)
.expect("move hello.txt");
repo.refresh_working_copy().expect("snapshot working copy");

let stats = repo.diff_file_stats("@", false).expect("diff file stats");
assert_eq!(stats.len(), 1, "same-basename move pairs: {stats:?}");
assert_eq!(stats[0].path, "moved/hello.txt");
assert_eq!((stats[0].insertions, stats[0].deletions), (0, 0));
}

#[test]
fn diff_file_stats_keeps_cross_basename_renames_as_two_entries() {
// diff_file_list pairs renames without content, so the cards show a deletion and an addition; stats must describe those cards, not a content-aware merge.
let temp_dir = init_jj_repo();
let repo_path = temp_dir.path().join("repo");
run_jj_in(&repo_path, &["new", "-m", "rename hello"]);
let repo = Repo::open(&repo_path).expect("open repo");

fs::rename(repo_path.join("hello.txt"), repo_path.join("greeting.txt"))
.expect("rename hello.txt");
repo.refresh_working_copy().expect("snapshot working copy");

let stats = repo.diff_file_stats("@", false).expect("diff file stats");
assert_eq!(stats.len(), 2, "cards stay split: {stats:?}");
let removed = stats
.iter()
.find(|s| s.path == "hello.txt")
.expect("removed entry");
let added = stats
.iter()
.find(|s| s.path == "greeting.txt")
.expect("added entry");
assert_eq!((removed.insertions, removed.deletions), (0, 1));
assert_eq!((added.insertions, added.deletions), (1, 0));
}

#[cfg(unix)]
#[test]
fn diff_file_stats_counts_symlinks_as_zero() {
let temp_dir = init_jj_repo();
let repo_path = temp_dir.path().join("repo");
let repo = Repo::open(&repo_path).expect("open repo");

std::os::unix::fs::symlink("hello.txt", repo_path.join("link.txt")).expect("create symlink");
repo.refresh_working_copy().expect("snapshot working copy");

let stats = repo.diff_file_stats("@", false).expect("diff file stats");
let link = stats
.iter()
.find(|file| file.path == "link.txt")
.expect("missing stats for link.txt");
assert_eq!((link.insertions, link.deletions), (0, 0));
}

#[test]
fn diff_file_stats_counts_auto_projected_plists_in_processed_mode() {
let fixture = FormatFixture::build();
let repo = Repo::open(&fixture.path).expect("open repo");
repo.refresh_working_copy().expect("snapshot working copy");

let stats = repo.diff_file_stats("@", false).expect("diff file stats");
let plist = stats
.iter()
.find(|file| file.path == FormatFixture::PLIST)
.expect("missing stats for the binary plist");
let projected = repo
.show_file("@", FormatFixture::PLIST)
.expect("projected plist hunk");
let xml_lines = projected
.new
.content
.as_deref()
.expect("projected content")
.lines()
.count() as u32;
assert_eq!((plist.insertions, plist.deletions), (xml_lines, 0));
}

#[test]
fn diff_file_stats_counts_binary_files_as_zero() {
let temp_dir = init_jj_repo();
let repo_path = temp_dir.path().join("repo");
let repo = Repo::open(&repo_path).expect("open repo");

fs::write(repo_path.join("blob.bin"), [0u8, 159, 146, 150, 0, 1]).expect("write binary file");
repo.refresh_working_copy().expect("snapshot working copy");

let stats = repo.diff_file_stats("@", false).expect("diff file stats");
let blob = stats
.iter()
.find(|file| file.path == "blob.bin")
.expect("missing stats for blob.bin");
assert_eq!((blob.insertions, blob.deletions), (0, 0));
}
116 changes: 116 additions & 0 deletions crates/jayjay-primitives/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,119 @@ pub struct DiffStats {
pub insertions: u32,
pub deletions: u32,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDiffStats {
pub path: String,
pub insertions: u32,
pub deletions: u32,
}

impl FileDiffStats {
pub fn total(&self) -> u32 {
self.insertions + self.deletions
}
}

pub const DIFF_EDIT_AUTO_EXPAND_MAX_FILES: usize = 30;
pub const DIFF_EDIT_AUTO_COLLAPSE_TOTAL_LINES: u64 = 1000;
pub const DIFF_EDIT_AUTO_COLLAPSE_FILE_LINES: u32 = 300;

pub fn diff_edit_starts_collapsed(file_count: usize, total_changed_lines: u64) -> bool {
file_count > DIFF_EDIT_AUTO_EXPAND_MAX_FILES
&& total_changed_lines > DIFF_EDIT_AUTO_COLLAPSE_TOTAL_LINES
}

pub fn diff_edit_collapses_while_stats_pending(file_count: usize) -> bool {
file_count > DIFF_EDIT_AUTO_EXPAND_MAX_FILES
}

pub fn diff_edit_auto_collapsed_paths(stats: &[FileDiffStats]) -> Vec<String> {
if stats.len() <= DIFF_EDIT_AUTO_EXPAND_MAX_FILES {
return Vec::new();
}
let total: u64 = stats.iter().map(|file| u64::from(file.total())).sum();
stats
.iter()
.filter(|file| {
total > DIFF_EDIT_AUTO_COLLAPSE_TOTAL_LINES
|| file.total() > DIFF_EDIT_AUTO_COLLAPSE_FILE_LINES
})
.map(|file| file.path.clone())
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

fn stats(path: &str, insertions: u32, deletions: u32) -> FileDiffStats {
FileDiffStats {
path: path.to_owned(),
insertions,
deletions,
}
}

fn small_files(count: usize) -> Vec<FileDiffStats> {
(0..count).map(|i| stats(&i.to_string(), 5, 2)).collect()
}

#[test]
fn diffs_at_the_file_cap_stay_expanded_even_when_huge() {
let mut files = small_files(DIFF_EDIT_AUTO_EXPAND_MAX_FILES - 1);
files.push(stats("huge", 5000, 5000));
assert!(diff_edit_auto_collapsed_paths(&files).is_empty());
}

#[test]
fn medium_diff_collapses_only_oversized_files() {
let mut files = small_files(DIFF_EDIT_AUTO_EXPAND_MAX_FILES);
files.push(stats("big", 400, 0));
assert_eq!(diff_edit_auto_collapsed_paths(&files), ["big"]);
}

#[test]
fn large_total_collapses_every_file() {
let mut files = small_files(DIFF_EDIT_AUTO_EXPAND_MAX_FILES);
files.push(stats("big", 900, 200));
let collapsed = diff_edit_auto_collapsed_paths(&files);
assert_eq!(collapsed.len(), files.len());
}

#[test]
fn small_diff_with_many_files_stays_expanded() {
assert!(
diff_edit_auto_collapsed_paths(&small_files(DIFF_EDIT_AUTO_EXPAND_MAX_FILES + 5))
.is_empty()
);
}

#[test]
fn entry_decision_matches_the_collapse_all_rule() {
assert!(diff_edit_starts_collapsed(
DIFF_EDIT_AUTO_EXPAND_MAX_FILES + 1,
1001
));
assert!(!diff_edit_starts_collapsed(
DIFF_EDIT_AUTO_EXPAND_MAX_FILES + 1,
1000
));
assert!(!diff_edit_starts_collapsed(
DIFF_EDIT_AUTO_EXPAND_MAX_FILES,
50_000
));
assert!(!diff_edit_starts_collapsed(0, 0));
}

#[test]
fn pending_stats_collapse_only_past_the_file_cap() {
assert!(diff_edit_collapses_while_stats_pending(
DIFF_EDIT_AUTO_EXPAND_MAX_FILES + 1
));
assert!(!diff_edit_collapses_while_stats_pending(
DIFF_EDIT_AUTO_EXPAND_MAX_FILES
));
assert!(!diff_edit_collapses_while_stats_pending(0));
}
}
16 changes: 16 additions & 0 deletions crates/jayjay-uniffi/src/diff.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
use jayjay_core::FileDiffStats;
use jayjay_core::diff::{
self, ChangeGroup, ConflictLineKind, DiffLine, DiffSpan, SideBySideRow, WrappedDiffLine,
WrappedSbsRow,
};

#[uniffi::export]
pub fn diff_edit_auto_collapsed_paths(stats: Vec<FileDiffStats>) -> Vec<String> {
jayjay_core::diff_edit_auto_collapsed_paths(&stats)
}

#[uniffi::export]
pub fn diff_edit_starts_collapsed(file_count: u64, total_changed_lines: u64) -> bool {
jayjay_core::diff_edit_starts_collapsed(file_count as usize, total_changed_lines)
}

#[uniffi::export]
pub fn diff_edit_collapses_while_stats_pending(file_count: u64) -> bool {
jayjay_core::diff_edit_collapses_while_stats_pending(file_count as usize)
}

#[uniffi::export]
pub fn build_side_by_side_rows(lines: Vec<DiffLine>) -> Vec<SideBySideRow> {
diff::build_side_by_side_rows(&lines)
Expand Down
Loading
Loading