From 5daf59e210f17a7f2263f3aeac3bcca426c6def5 Mon Sep 17 00:00:00 2001 From: hewigovens <360470+hewigovens@users.noreply.github.com> Date: Fri, 8 May 2026 09:50:46 +0900 Subject: [PATCH 1/3] feat: add near-term workflow improvements --- CONTRIBUTING.md | 2 +- README.md | 2 +- Roadmap.md | 102 ++++-- crates/jayjay-core/src/diffedit_plan.rs | 201 +++++++++++ crates/jayjay-core/src/evolog_display.rs | 143 ++++++++ crates/jayjay-core/src/jj_command.rs | 193 +++++++++++ crates/jayjay-core/src/lib.rs | 8 + crates/jayjay-core/src/repo/diffedit.rs | 53 +-- crates/jayjay-core/src/repo/github.rs | 42 ++- crates/jayjay-core/src/repo/mutations.rs | 27 +- .../jayjay-core/src/repo/mutations_files.rs | 4 + crates/jayjay-core/src/revsets.rs | 150 ++++++++ crates/jayjay-core/src/types/change.rs | 23 +- crates/jayjay-core/src/types/command.rs | 9 + crates/jayjay-core/src/types/mod.rs | 6 + crates/jayjay-core/src/types/rebase.rs | 6 + crates/jayjay-core/src/types/revset.rs | 8 + crates/jayjay-core/tests/real_jj_repo.rs | 21 +- crates/jayjay-uniffi/src/repo.rs | 148 +++++++- crates/jayjay-uniffi/src/types.rs | 50 ++- crates/jj-diff/README.md | 8 +- crates/jj-diff/src/compute.rs | 7 +- crates/jj-diff/src/context.rs | 36 +- crates/jj-diff/src/tests.rs | 33 +- crates/jj-diff/src/types.rs | 2 +- crates/jj-test-fixtures/src/cmd.rs | 5 +- docs/index.html | 2 +- shell/gpui/src/diff/annotate_view.rs | 6 +- shell/gpui/src/diff/diff_view/mouse.rs | 12 +- shell/gpui/src/diff/diff_view/sbs_body.rs | 104 +++--- shell/gpui/src/diff/diff_view/unified_body.rs | 23 +- shell/gpui/src/diff/mod.rs | 1 + shell/gpui/src/diff/selection.rs | 39 ++- shell/gpui/src/diff/wrap.rs | 323 ++++++++++++++++++ shell/gpui/src/log/detail/mod.rs | 3 +- shell/gpui/src/log/diff_select.rs | 4 +- shell/gpui/src/log/find.rs | 25 +- shell/gpui/src/log/nav.rs | 4 +- shell/gpui/src/log/sidebar.rs | 6 +- .../gpui/src/windows/command_palette/exec.rs | 19 +- .../gpui/src/windows/command_palette/input.rs | 14 +- .../src/windows/command_palette/render.rs | 17 +- .../gpui/src/windows/command_palette/state.rs | 4 +- shell/gpui/src/windows/evolog.rs | 63 +++- shell/gpui/src/windows/file_history.rs | 6 +- .../JayJayDiffUI/DiffGutterTextView.swift | 58 ++-- .../JayJayDiffUI/DiffTextContainerView.swift | 20 ++ .../JayJayDiffUI/DiffTextSupport.swift | 94 +++-- .../JayJayDiffUI/NativeDiffView+Gutter.swift | 6 - .../NativeDiffView+WrappedGutter.swift | 202 +++++++++++ .../Sources/JayJayDiffUI/NativeDiffView.swift | 155 +++------ .../SideBySideDiffRendering.swift | 12 +- .../DiffLayoutManagerTests.swift | 38 ++- .../JayJay/App/Config/AppSettings.swift | 48 +++ .../JayJay/App/Config/SavedRevset.swift | 7 + .../Sources/JayJay/Detail/DetailView.swift | 4 +- .../Sources/JayJay/Detail/EvologDisplay.swift | 40 ++- .../Sources/JayJay/Detail/EvologView.swift | 108 +++++- .../JayJay/Detail/EvologViewModel.swift | 75 +++- .../mac/Sources/JayJay/Diff/DiffSection.swift | 42 +-- .../JayJay/DiffEdit/DiffEditFileSection.swift | 60 ++-- .../JayJay/DiffEdit/DiffEditModels.swift | 105 ++++-- .../JayJay/DiffEdit/DiffEditView.swift | 171 ++++++++-- .../JayJay/Repo/DAGRebaseGesturePolicy.swift | 24 +- .../Sources/JayJay/Repo/DAGRebaseModels.swift | 39 +++ .../JayJay/Repo/DAGRow+RebaseChrome.swift | 94 +++++ shell/mac/Sources/JayJay/Repo/DAGRow.swift | 81 +---- .../Sources/JayJay/Repo/DAGRowViewModel.swift | 24 +- .../JayJay/Repo/DAGView+RebaseDrag.swift | 26 +- shell/mac/Sources/JayJay/Repo/DAGView.swift | 12 +- .../Repo/RepoContentView+CommandPalette.swift | 42 ++- .../Repo/RepoContentView+Presentation.swift | 85 +---- .../RepoContentView+RebasePresentation.swift | 98 ++++++ .../JayJay/Repo/RepoPresentation.swift | 14 +- .../mac/Sources/JayJay/Repo/RepoSidebar.swift | 85 ++++- .../mac/Sources/JayJay/Repo/RepoWindow.swift | 4 +- .../Actions/RepoViewModel+ChangeActions.swift | 12 +- .../Actions/RepoViewModel+Evolog.swift | 9 + .../Actions/RepoViewModel+Rebase.swift | 119 ++++--- .../Sources/JayJay/Shared/ChangeActions.swift | 3 +- .../JayJay/Shared/CommandPalette+RawJJ.swift | 162 +++++++++ .../JayJay/Shared/CommandPalette.swift | 191 +++-------- .../JayJay/Shared/CommandPalettePanel.swift | 61 ++++ .../JayJay/Shared/CommandPaletteSupport.swift | 41 +++ .../JayJayTests/AppSettingsRevsetTests.swift | 55 +++ .../CommandPaletteSupportTests.swift | 72 ++++ .../DAGRebaseGesturePolicyTests.swift | 55 ++- .../JayJayTests/DAGRowViewModelTests.swift | 5 +- .../JayJayTests/DiffEditSelectionTests.swift | 51 +++ .../JayJayTests/EvologDisplayTests.swift | 18 + .../JayJayTests/EvologViewModelTests.swift | 85 +++++ 91 files changed, 3763 insertions(+), 1013 deletions(-) create mode 100644 crates/jayjay-core/src/diffedit_plan.rs create mode 100644 crates/jayjay-core/src/evolog_display.rs create mode 100644 crates/jayjay-core/src/jj_command.rs create mode 100644 crates/jayjay-core/src/revsets.rs create mode 100644 crates/jayjay-core/src/types/command.rs create mode 100644 crates/jayjay-core/src/types/rebase.rs create mode 100644 crates/jayjay-core/src/types/revset.rs create mode 100644 shell/gpui/src/diff/wrap.rs create mode 100644 shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+WrappedGutter.swift create mode 100644 shell/mac/Sources/JayJay/App/Config/SavedRevset.swift create mode 100644 shell/mac/Sources/JayJay/Repo/DAGRow+RebaseChrome.swift create mode 100644 shell/mac/Sources/JayJay/Repo/RepoContentView+RebasePresentation.swift create mode 100644 shell/mac/Sources/JayJay/Shared/CommandPalette+RawJJ.swift create mode 100644 shell/mac/Sources/JayJay/Shared/CommandPalettePanel.swift create mode 100644 shell/mac/Sources/JayJay/Shared/CommandPaletteSupport.swift create mode 100644 shell/mac/Tests/JayJayTests/AppSettingsRevsetTests.swift create mode 100644 shell/mac/Tests/JayJayTests/CommandPaletteSupportTests.swift create mode 100644 shell/mac/Tests/JayJayTests/DiffEditSelectionTests.swift create mode 100644 shell/mac/Tests/JayJayTests/EvologDisplayTests.swift create mode 100644 shell/mac/Tests/JayJayTests/EvologViewModelTests.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b67d513..392311c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,7 @@ Current `jj-lib`-backed areas: - Log, revset parsing, show/diff, bookmark data, diffedit application, most core mutations, working-copy refresh Current `jj` CLI-backed areas: -- `resolve`, `workspace`, `undo` (`jj op`), `split`, `graft`, `duplicate`, `absorb`, `backout`, parts of Git integration, AI commit-message helpers +- `resolve`, `workspace`, `undo` (`jj op`), `split`, `graft`, `duplicate`, `absorb`, `revert`, parts of Git integration, AI commit-message helpers When adding a feature: 1. Put business logic in Rust first. diff --git a/README.md b/README.md index 8473e95..e13ea72 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ for line in &collapsed.diff.lines { JayJay is a native macOS GUI for [Jujutsu (jj)](https://github.com/jj-vcs/jj), the modern version control system that reimagines the git model with mutable history, changes instead of commits, and bookmarks instead of branches. JayJay is built with Rust (wrapping `jj-lib` directly — no CLI scraping) and SwiftUI. **Is there a GUI for Jujutsu?** -Yes — JayJay is a full-featured native macOS **jj GUI**. It gives you DAG visualization, unified + side-by-side diffs with tree-sitter syntax highlighting, interdiff (PR-style revision comparison), diff edit mode, file annotate, one-click conflict resolution, and every common jj operation (`squash`, `split`, `graft`, `absorb`, `backout`, `merge`, `duplicate`, `describe`, `abandon`) — without memorizing the command flags. +Yes — JayJay is a full-featured native macOS **jj GUI**. It gives you DAG visualization, unified + side-by-side diffs with tree-sitter syntax highlighting, interdiff (PR-style revision comparison), diff edit mode, file annotate, one-click conflict resolution, and every common jj operation (`squash`, `split`, `graft`, `absorb`, `revert`, `merge`, `duplicate`, `describe`, `abandon`) — without memorizing the command flags. **How do I install JayJay on macOS?** `brew install --cask hewigovens/tap/jayjay` is the easiest path. Or download the signed, notarized zip from [GitHub Releases](https://github.com/hewigovens/jayjay/releases/latest). Requires macOS 15 Sequoia or later. diff --git a/Roadmap.md b/Roadmap.md index 847c940..4427734 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -2,39 +2,57 @@ JayJay now covers most common jj history, diff, bookmark, conflict, and Git flows, plus raw `jj` execution via `!` in the command palette. The next phase is less about basic command parity and more about making jj-native workflows feel faster and more visual than the CLI. -## Near-term - -- [ ] Stack surgery polish (`jj rebase --after` / `--before` and related flows) - Current baseline: drag-to-rebase already handles "onto". Next: make insert-after / insert-before flows, descendant behavior, and previews clearer and more visual -- [ ] Diff edit polish - Next: change-wide select all / clear all, stronger unsupported-file messaging, better topology copy -- [ ] Saved revsets library - Goal: move beyond the six preset chips. Ship a named revset library (authored by you, touching file, fork point of x, commits with no children, etc.) and a "save this revset" action so users can build their own -- [ ] Command palette polish - Next: command history, better inline output, and better discoverability for `jj ...` / `! ...` -- [ ] Evolog polish - Current baseline: read-only viewer with interdiff against current and "Copy `jj restore` command" — already useful for recovery. Next: inline restore action, hide-snapshots toggle, run-of-snapshots collapsing - ## Longer-term -- [ ] GPUI shell (Alpha) — Linux + Windows native shell using GPUI (one Rust shell, identical look across both). Mac stays on SwiftUI. OS integration via freedesktop standards (`.desktop`, hicolor, D-Bus via `notify-rust` / `ashpd` / `zbus`) — no GTK dependency. - - [x] Read-only milestone — at parity with the SwiftUI shell for read flows: - - [x] Per-file history viewer — surfaced from the file row "Show History" context menu - - [x] Auto-refresh on filesystem changes — `notify`-based watcher on `.jj/repo/op_heads/heads` + working tree, debounced - - [x] Onboarding / no-repo state — welcome card with `jj git init` hint when the path isn't a jj repo - - [x] Reveal-to-changeId — `LogView::reveal_change_id` scrolls + selects, used by file-history and bookmark clicks - - [x] Bookmark bar in sidebar header + workspace pill in status bar (read-only; switching is a write-action, deferred) - - [x] Persistent file review (space to toggle, `n / total reviewed` count, content-hash auto-invalidation so a re-edited file flips back to unreviewed; survives jj rebases that produce identical content) — `jayjay_core::review::ReviewStore` is the canonical impl; SwiftUI's UserDefaults-backed copy is the next migration target. - - [x] Diff view selection + copy — column-precision cross-line text selection in unified and side-by-side (per-side, independent) diffs, custom selection layer on the gutter/content split, gutter excluded from copy structurally. Cmd+C copies the joined slice; double-click selects a word; glyph advance measured via `text_system::ch_advance` so multi-line highlights stop at each line's actual EOL (matches VSCode/GitHub Desktop). Same trim applied to the SwiftUI diff via `DiffLayoutManager.rectArray` override. Polish remaining: cross-hunk-gap selection through `…N hidden lines…` separators, triple-click line-select, Cmd+A select-all. - - [ ] Write milestone — first set of mutating actions, all routed through the existing `RepoViewModel::refresh()` so the FS watcher + review store stay coherent: - - [ ] Describe + commit box (edit working-copy description, AI message generation reusing `jayjay_core::COMMIT_MESSAGE_PROMPT`) - - [ ] `jj new` button on the toolbar - - [ ] Abandon / squash-into-parent from the change context menu, with confirmation sheet - - [ ] Split (file-level) using the read-only review checkboxes as the selection model — closes the loop on the persistent review store - - [ ] Bookmark create / move-forward / push, surfaced from the existing bookmark picker dropdown - - [ ] Undo via `jj op log` (`⌘⇧U`), mirroring the SwiftUI shortcut - - [ ] Drag-to-rebase + conflict resolve — DAG row drag with hover preview + confirmation sheet; basic `jj resolve` UI (sidecar diff, "Use Ours/Theirs" buttons). Higher complexity, deferred until the basic write actions land. - - [ ] Linux/Windows polish — `.desktop` entry + hicolor icon set, D-Bus notifications via `notify-rust`/`zbus` for long-running ops, file picker fallback when `gpui::Window::prompt_for_paths` isn't available on the target platform. +- [ ] GPUI shell feature parity — Linux + Windows native shell using GPUI (one Rust shell, identical look across both). Mac stays on SwiftUI until the GPUI shell proves parity. OS integration via freedesktop standards (`.desktop`, hicolor, D-Bus via `notify-rust` / `ashpd` / `zbus`) — no GTK dependency. + - Definition of parity: every user-visible SwiftUI feature is either implemented in GPUI, deliberately marked macOS-only, or explicitly cut from both shells. Every shipped GPUI feature has a hermetic `#[gpui::test]` component test or a stronger end-to-end check. + - [ ] Read/navigation parity: + - [ ] Revset filter UI with editable expression, preset chips (All, Mine, Bookmarks, Trunk, Conflicts, Heads), reset-to-default, load-more semantics, and clear empty/error states. + - [ ] Saved revsets library and command palette revset commands, matching the completed SwiftUI workflow. + - [ ] Command palette parity: refresh, view toggles, revset presets, Git actions, bookmark manager, change actions, workspace actions, zoom, Show in Finder / file manager, View Remote Repository, editor/terminal, undo, settings, and raw `jj` command history/output polish. + - [ ] Interdiff / compare mode: shift-click and context-menu compare, compare banner, clear compare, and arbitrary `from`/`to` file diffs. + - [ ] DAG context menu parity: new, edit/switch, compare, rebase selected onto target, squash selected into target, merge with selected, create bookmark, evolog, graft, duplicate, absorb, revert, abandon, plus divergent-change wording. + - [ ] Bookmark chip/menu parity in DAG rows: move to `@-`, push, pull request on GitHub, and copy bookmark name. + - [ ] Status bar parity: conflict-count action, selected-bookmark PR link/check state, change count, repo path, workspace open/forget/delete actions. + - [ ] File column parity: search/filter, hide reviewed files, split reviewed files shortcut, conflict badges, multi-select and shift-select, batch context actions, hidden Git LFS/submodule counts, and settings-backed filtering. + - [ ] Non-jj folder onboarding action that actually runs `jj git init`, not only a hint. + - [ ] Diff/review parity: + - [ ] Hunk-level review checkboxes, reviewed-hunk persistence, file auto-promotion when all hunks are reviewed, and materialized survivors when unmarking a file-marked hunk. + - [ ] Diff edit mode: change-wide select all / clear all, file/hunk/line selection, new-child destination, parallel destination, move-to-working-copy destination, topology-aware destination copy, and unsupported-file messaging. + - [ ] Inline line actions: abandon selected working-copy lines and open selected lines in diff edit. + - [ ] Conflict UI: conflicted-path loading, per-file conflict bar, Use Ours, Use Theirs, resolve with configured merge tool, and post-action refresh/toast. + - [ ] Image/SVG parity with SwiftUI: rendered SVG toggle where supported, clear unsupported placeholders, and consistent new/deleted/modified image layouts. + - [ ] Diff selection polish remaining from the baseline: cross-hunk-gap selection through `…N hidden lines…` separators, triple-click line-select, Cmd+A select-all. + - [ ] Write/action parity: + - [ ] Shared GPUI mutation path around `RepoViewModel::perform`-style helpers: detached work, friendly errors, success messages, refresh selection, review-store cleanup, and FS-watcher loop suppression. + - [ ] Presentation primitives matching SwiftUI: inline empty/error states, blocking HUD only for unsafe operations, toast with optional undo action, alerts, and sheets. + - [ ] Describe/edit message for any change and working-copy commit box with AI message generation using `jayjay_core::COMMIT_MESSAGE_PROMPT`. + - [ ] Commit working copy, including submodule-attention flow and safe submodule update commit. + - [ ] `jj new`, edit/switch, abandon with confirmation, squash into parent, squash into selected target, rebase, merge, duplicate, graft, absorb, and revert. + - [ ] File actions: split, parallel split, move to working copy, restore to parent, delete from disk, ignore & untrack, and batch actions over multi-selection. + - [ ] Drag-to-rebase with arm delay, hover preview, confirmation sheet, Return/Escape handling, undo toast, and conflict follow-up. + - [ ] Undo via `jj op log` (`Cmd/Ctrl+Shift+U`) with restore action and operation labels. + - [ ] Bookmark / Git / GitHub parity: + - [ ] Bookmark create, rename, delete, track remote, move forward, filter by bookmark, and full Bookmark Manager. + - [ ] Git fetch/pull, push all, push selected bookmark, auto-track push result handling, and clean up stale bookmarks. + - [ ] Open existing PR or GitHub compose URL from bookmarks; keep the status-bar PR/checks surface in sync. + - [ ] View Remote Repository action with `git@` to `https` conversion. + - [ ] Workspace / window / app-shell parity: + - [ ] New workspace, open existing workspace, forget workspace, forget-and-delete workspace, and recent repo list updates. + - [ ] Multi-window repo management, URL scheme / CLI handoff to running instance, repo-window deduplication, and active-repo command routing. + - [ ] Onboarding wizard with jj environment check, recent repositories, GitHub Desktop warning, and first-run state. + - [ ] Help menu/actions: GitHub, jj docs, report issue, sponsor prompt policy if GPUI ships as a primary shell. + - [ ] Settings / platform parity: + - [ ] Appearance settings are fully interactive: system appearance detection, font family picker, font size controls, and Cmd/Ctrl +/-/0 zoom shortcuts. + - [ ] Diff/settings values are wired through all views, especially Git LFS hiding, submodule hiding/support, abandon confirmation, and drag-rebase confirmation. + - [ ] Tools settings can edit custom editor/terminal commands in-app, and Open in Editor / Terminal works cross-platform. + - [ ] Environment status view for `jj`, `gh`, Codex CLI, Claude CLI, and platform AI providers. + - [ ] Document or replace macOS-only SwiftUI surfaces: Sparkle updates, notarized release packaging, AppKit menus, Finder integration, and bundled CLI symlink installer. + - [ ] GPUI package/install story: app icon set, `.desktop` entry, Windows metadata, D-Bus/toast notifications for long-running ops, file picker fallback, and persisted window placement on all targets. + - [ ] Test parity: + - [ ] Expand from the current single GPUI smoke test to component tests for selection, refresh, revset, diff loading, file review, command palette, settings persistence, and every mutation action. + - [ ] Add deterministic fixtures mirroring SwiftUI UI scenes: file diff, annotate, interdiff, command palette, new change, review split, undo, bookmark manager, and conflict resolution. + - [ ] Parity is not done until each SwiftUI UI test scene has a GPUI equivalent or a documented macOS-only exemption. - [ ] Tag UI (`jj tag ...`) once jj stabilizes the model and command surface - [ ] Multi-repo tabs or richer workspace switching model - [ ] Advanced DAG reordering @@ -49,8 +67,26 @@ JayJay now covers most common jj history, diff, bookmark, conflict, and Git flow ## Done +### Recent Workflow Polish +- [x] Stack surgery polish (`jj rebase --after` / `--before` and related flows) + Drag-to-rebase now supports onto, insert-after, and insert-before zones with clearer previews, confirmation copy that calls out descendant movement, context-menu insert actions, and core bindings for `jj rebase -r ... --insert-after/--insert-before` +- [x] Diff edit polish + Added change-wide select all / clear all, stronger unsupported-file messaging, and topology copy that explains what each destination does to the source, child, sibling, or working copy +- [x] Saved revsets library + Added built-in named revsets for common jj queries plus user-saved revsets with save/delete UI and command-palette commands +- [x] Command palette polish + Added raw jj command history, richer inline output with exit status and copy action, parsing for quoted arguments, suggestions, and clearer discoverability for `jj ...` / `! ...` +- [x] Evolog polish + Added inline restore to `@`, hide-snapshots toggle, and run-of-snapshots collapsing + +### GPUI Shell +- [x] Read-only baseline: + - [x] DAG, lane rendering, keyboard navigation, detail header/description, stats, avatars, unified + side-by-side diffs, image previews, annotate, file history, evolog, PR status fetch, bookmark picker, workspace picker, settings window, command palette basics, diff find, diff text selection/copy, column resizing, description resizing, auto-refresh, and no-repo placeholder. + - [x] Persistent working-copy file review (space to toggle, `n / total reviewed`, content-hash auto-invalidation, survives jj rebases that produce identical content) — `jayjay_core::review::ReviewStore` is the canonical impl; SwiftUI's UserDefaults-backed copy is the next migration target. + - [x] Diff view selection + copy — column-precision cross-line text selection in unified and side-by-side diffs; Cmd+C copies the joined slice; double-click selects a word; gutter is excluded structurally. + ### Major Milestones -- [x] Absorb + Backout support (`jj absorb` / `jj backout`) — [#2](https://github.com/hewigovens/jayjay/issues/2) +- [x] Absorb + Revert support (`jj absorb` / `jj revert`) — [#2](https://github.com/hewigovens/jayjay/issues/2) - [x] Interdiff between arbitrary revisions (`jj diff --from X --to Y`) — [#4](https://github.com/hewigovens/jayjay/issues/4) - [x] Conflict resolution UI (`jj resolve`) — [#1](https://github.com/hewigovens/jayjay/issues/1) - [x] File annotate / blame view (`jj file annotate`) — [#3](https://github.com/hewigovens/jayjay/issues/3) @@ -61,7 +97,7 @@ JayJay now covers most common jj history, diff, bookmark, conflict, and Git flow - [x] Landing page (GitHub Pages) ### Rust Core -- jj-lib: open, log, log_graph, show, describe, new, edit, squash, squash --into, abandon, rebase, split, graft, duplicate, merge, absorb, backout +- jj-lib: open, log, log_graph, show, describe, new, edit, squash, squash --into, abandon, rebase, split, graft, duplicate, merge, absorb, revert - Evolog: in-process via `jj_lib::evolution::walk_predecessors` (no CLI shell-out) - File annotate (blame): in-process via `jj_lib::annotate::FileAnnotator` (no CLI shell-out) - File history: type-safe revset built from `RevsetExpression::filter` + `FilesetExpression::file_path` (no string formatting) diff --git a/crates/jayjay-core/src/diffedit_plan.rs b/crates/jayjay-core/src/diffedit_plan.rs new file mode 100644 index 0000000..9393eee --- /dev/null +++ b/crates/jayjay-core/src/diffedit_plan.rs @@ -0,0 +1,201 @@ +use crate::diff::{DiffSpanStyle, FileDiff, is_editable_text, is_git_lfs, is_git_submodule}; +use crate::{DiffEditFileSelection, DiffEditRange, DiffHunk, HunkType}; + +pub fn diff_edit_changed_lines(diff: &FileDiff) -> Vec { + diff.lines + .iter() + .enumerate() + .filter_map(|(index, line)| match line.style { + DiffSpanStyle::Added | DiffSpanStyle::Removed => Some((index + 1) as u32), + _ => None, + }) + .collect() +} + +pub fn diff_edit_supports_file( + hunk_type: HunkType, + old_content: Option<&str>, + new_content: Option<&str>, +) -> bool { + hunk_type != HunkType::Renamed && is_editable_text(old_content) && is_editable_text(new_content) +} + +pub fn diff_edit_unsupported_reason( + hunk_type: HunkType, + old_content: Option<&str>, + new_content: Option<&str>, +) -> Option { + if hunk_type == HunkType::Renamed { + return Some("renamed files cannot be split by line yet".to_owned()); + } + if is_git_lfs(old_content) || is_git_lfs(new_content) { + return Some("Git LFS pointer placeholder".to_owned()); + } + if is_git_submodule(old_content) || is_git_submodule(new_content) { + return Some("submodule pointer change".to_owned()); + } + if !is_editable_text(old_content) || !is_editable_text(new_content) { + return Some("binary, conflicted, directory, or inaccessible content".to_owned()); + } + None +} + +pub fn build_diff_edit_file_selection( + hunk: &DiffHunk, + diff: &FileDiff, + old_content: Option, + new_content: Option, + selected_lines: &[u32], + inverse: bool, +) -> Option { + if !diff_edit_supports_file( + hunk.hunk_type, + old_content.as_deref(), + new_content.as_deref(), + ) { + return None; + } + + let changed_lines = diff_edit_changed_lines(diff); + let selected: std::collections::BTreeSet = selected_lines.iter().copied().collect(); + let line_numbers = changed_lines + .into_iter() + .filter(|line| selected.contains(line) != inverse) + .collect::>(); + let line_ranges = collapse_ranges(&line_numbers); + if line_ranges.is_empty() { + return None; + } + + Some(DiffEditFileSelection { + path: hunk.path.clone(), + old_path: hunk.old_path.clone(), + old_content, + new_content, + hunk_type: hunk.hunk_type, + line_ranges, + }) +} + +fn collapse_ranges(line_numbers: &[u32]) -> Vec { + let Some((&first, rest)) = line_numbers.split_first() else { + return Vec::new(); + }; + let mut ranges = Vec::new(); + let mut start = first; + let mut previous = first; + + for &line_number in rest { + if line_number == previous + 1 { + previous = line_number; + continue; + } + ranges.push(DiffEditRange { + start_line: start, + end_line: previous, + }); + start = line_number; + previous = line_number; + } + ranges.push(DiffEditRange { + start_line: start, + end_line: previous, + }); + ranges +} + +#[cfg(test)] +mod tests { + use crate::diff::{DiffLine, DiffSpanStyle}; + + use super::*; + + #[test] + fn changed_lines_are_one_based_added_or_removed() { + let diff = FileDiff { + path: "file.txt".to_owned(), + language: "text".to_owned(), + lines: vec![ + line(DiffSpanStyle::Context), + line(DiffSpanStyle::Removed), + line(DiffSpanStyle::Added), + ], + whitespace_only_hidden: false, + }; + assert_eq!(diff_edit_changed_lines(&diff), vec![2, 3]); + } + + #[test] + fn builds_selected_and_inverse_ranges() { + let hunk = DiffHunk { + path: "file.txt".to_owned(), + old_path: None, + old_content: Some("a\nb\n".to_owned()), + new_content: Some("a\nc\n".to_owned()), + old_preview: None, + new_preview: None, + hunk_type: HunkType::Modified, + review_identity: "identity".to_owned(), + }; + let diff = FileDiff { + path: "file.txt".to_owned(), + language: "text".to_owned(), + lines: vec![ + line(DiffSpanStyle::Removed), + line(DiffSpanStyle::Added), + line(DiffSpanStyle::Context), + line(DiffSpanStyle::Added), + ], + whitespace_only_hidden: false, + }; + + let selected = build_diff_edit_file_selection( + &hunk, + &diff, + hunk.old_content.clone(), + hunk.new_content.clone(), + &[1, 2], + false, + ) + .unwrap(); + assert_eq!(selected.line_ranges[0].start_line, 1); + assert_eq!(selected.line_ranges[0].end_line, 2); + + let inverse = build_diff_edit_file_selection( + &hunk, + &diff, + hunk.old_content.clone(), + hunk.new_content.clone(), + &[1, 2], + true, + ) + .unwrap(); + assert_eq!(inverse.line_ranges[0].start_line, 4); + } + + #[test] + fn reports_placeholder_reasons() { + assert_eq!( + diff_edit_unsupported_reason(HunkType::Modified, Some(""), None), + Some("Git LFS pointer placeholder".to_owned()) + ); + assert_eq!( + diff_edit_unsupported_reason(HunkType::Modified, Some(""), None), + Some("submodule pointer change".to_owned()) + ); + assert_eq!( + diff_edit_unsupported_reason(HunkType::Modified, Some(""), None), + Some("binary, conflicted, directory, or inaccessible content".to_owned()) + ); + } + + fn line(style: DiffSpanStyle) -> DiffLine { + DiffLine { + old_line_no: None, + new_line_no: None, + style, + spans: Vec::new(), + no_eof_newline: false, + } + } +} diff --git a/crates/jayjay-core/src/evolog_display.rs b/crates/jayjay-core/src/evolog_display.rs new file mode 100644 index 0000000..da92b89 --- /dev/null +++ b/crates/jayjay-core/src/evolog_display.rs @@ -0,0 +1,143 @@ +use crate::{EvologEntry, EvologOperationKind, EvologVisibleRow}; + +pub fn evolog_operation_kind(raw: &str) -> EvologOperationKind { + if raw.starts_with("snapshot working copy") { + EvologOperationKind::Snapshot + } else if raw.starts_with("describe commit ") { + EvologOperationKind::Describe + } else if raw.starts_with("rebase commit ") { + EvologOperationKind::Rebase + } else if raw.starts_with("squash commits ") { + EvologOperationKind::Squash + } else if raw.starts_with("split commit ") { + EvologOperationKind::Split + } else if raw.starts_with("new empty commit") { + EvologOperationKind::New + } else if raw.is_empty() { + EvologOperationKind::Rewrite + } else { + EvologOperationKind::Other + } +} + +pub fn evolog_is_snapshot(raw: &str) -> bool { + evolog_operation_kind(raw) == EvologOperationKind::Snapshot +} + +pub fn evolog_visible_rows( + entries: &[EvologEntry], + hide_snapshots: bool, + collapse_snapshot_runs: bool, +) -> Vec { + let mut rows = Vec::new(); + let mut index = 0; + while index < entries.len() { + let entry = &entries[index]; + if hide_snapshots && evolog_is_snapshot(&entry.operation) { + index += 1; + continue; + } + + if collapse_snapshot_runs && evolog_is_snapshot(&entry.operation) { + let start = index; + let mut group_entries = Vec::new(); + let mut group_indices = Vec::new(); + while index < entries.len() && evolog_is_snapshot(&entries[index].operation) { + group_entries.push(entries[index].clone()); + group_indices.push(index as u32); + index += 1; + } + rows.push(EvologVisibleRow { + id: format!("snapshots-{start}-{}", group_entries.len()), + primary_index: start as u32, + indices: group_indices, + is_snapshot_run: group_entries.len() > 1, + entries: group_entries, + }); + } else { + rows.push(EvologVisibleRow { + id: format!("entry-{index}"), + primary_index: index as u32, + indices: vec![index as u32], + entries: vec![entry.clone()], + is_snapshot_run: false, + }); + index += 1; + } + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_operation_prefixes() { + assert_eq!( + evolog_operation_kind("snapshot working copy 123"), + EvologOperationKind::Snapshot + ); + assert_eq!(evolog_operation_kind(""), EvologOperationKind::Rewrite); + assert_eq!( + evolog_operation_kind("rebase commit abc"), + EvologOperationKind::Rebase + ); + } + + #[test] + fn collapses_snapshot_runs() { + let entries = vec![ + entry("a", "snapshot working copy"), + entry("b", "snapshot working copy"), + entry("c", "describe commit x"), + entry("d", "snapshot working copy"), + ]; + + let rows = evolog_visible_rows(&entries, false, true); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].indices, vec![0, 1]); + assert!(rows[0].is_snapshot_run); + assert_eq!(rows[1].primary_index, 2); + assert_eq!(rows[2].indices, vec![3]); + assert!(!rows[2].is_snapshot_run); + } + + #[test] + fn hides_snapshots_without_collapsing() { + let entries = vec![ + entry("a", "snapshot working copy"), + entry("b", "describe commit x"), + entry("c", "snapshot working copy"), + ]; + + let rows = evolog_visible_rows(&entries, true, false); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].primary_index, 1); + assert_eq!(rows[0].entries[0].commit_id, "b"); + } + + #[test] + fn hide_snapshots_takes_precedence_over_collapse() { + let entries = vec![ + entry("a", "snapshot working copy"), + entry("b", "snapshot working copy"), + entry("c", "describe commit x"), + ]; + + let rows = evolog_visible_rows(&entries, true, true); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].primary_index, 2); + assert!(!rows[0].is_snapshot_run); + } + + fn entry(commit_id: &str, operation: &str) -> EvologEntry { + EvologEntry { + change_id: "change".to_owned(), + commit_id: commit_id.to_owned(), + timestamp_millis: 0, + operation: operation.to_owned(), + description: String::new(), + } + } +} diff --git a/crates/jayjay-core/src/jj_command.rs b/crates/jayjay-core/src/jj_command.rs new file mode 100644 index 0000000..3f0232b --- /dev/null +++ b/crates/jayjay-core/src/jj_command.rs @@ -0,0 +1,193 @@ +use std::path::Path; +use std::process::Command; + +use crate::{CoreError, CoreResult, JjCommandRun, jj_binary}; + +pub fn jj_command_body(query: &str) -> Option { + let body_after = |rest: &str| rest.trim_start().to_owned(); + if query == "jj" || query == "!" { + return Some(String::new()); + } + if let Some(rest) = query.strip_prefix("jj ") { + return Some(body_after(rest)); + } + query.strip_prefix('!').map(body_after) +} + +pub fn parse_jj_command_args(command: &str) -> Option> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaping = false; + let mut arg_started = false; + + for ch in command.chars() { + if escaping { + current.push(ch); + escaping = false; + arg_started = true; + continue; + } + if ch == '\\' { + escaping = true; + arg_started = true; + continue; + } + if let Some(current_quote) = quote { + if ch == current_quote { + quote = None; + } else { + current.push(ch); + } + arg_started = true; + continue; + } + if ch == '"' || ch == '\'' { + quote = Some(ch); + arg_started = true; + continue; + } + if ch.is_whitespace() { + if arg_started { + args.push(std::mem::take(&mut current)); + arg_started = false; + } + continue; + } + current.push(ch); + arg_started = true; + } + + if escaping { + current.push('\\'); + } + quote.is_none().then(|| { + if arg_started { + args.push(current); + } + args + }) +} + +pub fn record_jj_command_history(command: &str, existing: &[String], limit: usize) -> Vec { + let trimmed = command.trim(); + if trimmed.is_empty() { + return existing.to_vec(); + } + + let mut values = Vec::with_capacity(existing.len().min(limit).saturating_add(1)); + values.push(trimmed.to_owned()); + values.extend( + existing + .iter() + .filter(|item| item.as_str() != trimmed) + .cloned(), + ); + values.truncate(limit); + values +} + +pub fn run_jj_command_in_path(path: &Path, command: &str) -> CoreResult { + let args = parse_jj_command_args(command).ok_or_else(|| CoreError::Internal { + message: "Unclosed quote in jj command.".to_owned(), + })?; + if args.is_empty() { + return Err(CoreError::Internal { + message: "No jj command to run.".to_owned(), + }); + } + + let output = Command::new(jj_binary()) + .args(&args) + .current_dir(path) + .output() + .map_err(|e| CoreError::Internal { + message: format!("run jj: {e}"), + })?; + + let stdout = trim_output(&output.stdout); + let stderr = trim_output(&output.stderr); + let combined = match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => "(no output)".to_owned(), + (false, true) => stdout.clone(), + (true, false) => stderr.clone(), + (false, false) => format!("{stdout}\n{stderr}"), + }; + + Ok(JjCommandRun { + display: format!("jj {command}"), + stdout, + stderr, + output: combined, + exit_code: output.status.code().unwrap_or(-1), + success: output.status.success(), + }) +} + +fn trim_output(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).trim().to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_shell_like_args() { + assert_eq!( + parse_jj_command_args(r#"log -r "description(exact:'fix bug')" --limit 5"#), + Some(vec![ + "log".to_owned(), + "-r".to_owned(), + "description(exact:'fix bug')".to_owned(), + "--limit".to_owned(), + "5".to_owned() + ]) + ); + assert_eq!( + parse_jj_command_args(r#"file\ with\ spaces"#), + Some(vec!["file with spaces".to_owned()]) + ); + assert_eq!( + parse_jj_command_args(r#"describe -m "a\"b""#), + Some(vec![ + "describe".to_owned(), + "-m".to_owned(), + "a\"b".to_owned() + ]) + ); + assert_eq!( + parse_jj_command_args(r#"file\"#), + Some(vec!["file\\".to_owned()]) + ); + assert_eq!( + parse_jj_command_args(r#"describe -m """#), + Some(vec!["describe".to_owned(), "-m".to_owned(), String::new()]) + ); + assert_eq!( + parse_jj_command_args(r#"new '' file\ with\ spaces"#), + Some(vec![ + "new".to_owned(), + String::new(), + "file with spaces".to_owned() + ]) + ); + assert_eq!(parse_jj_command_args(r#"log -r "mine()"#), None); + } + + #[test] + fn extracts_prefixed_command_body() { + assert_eq!(jj_command_body("jj log"), Some("log".to_owned())); + assert_eq!(jj_command_body("!status"), Some("status".to_owned())); + assert_eq!(jj_command_body("status"), None); + } + + #[test] + fn records_history_at_front_without_duplicates() { + let existing = vec!["status".to_owned(), "log".to_owned()]; + assert_eq!( + record_jj_command_history(" log ", &existing, 20), + vec!["log".to_owned(), "status".to_owned()] + ); + } +} diff --git a/crates/jayjay-core/src/lib.rs b/crates/jayjay-core/src/lib.rs index c2e8c44..6f632dc 100644 --- a/crates/jayjay-core/src/lib.rs +++ b/crates/jayjay-core/src/lib.rs @@ -1,13 +1,21 @@ pub use jj_diff as diff; pub use jj_diff::syntax; pub mod dag; +pub mod diffedit_plan; +pub mod evolog_display; pub mod file_tree; pub mod hash; +pub mod jj_command; mod repo; pub mod review; +pub mod revsets; pub mod tools; mod types; +pub use evolog_display::{evolog_is_snapshot, evolog_operation_kind, evolog_visible_rows}; +pub use jj_command::{ + jj_command_body, parse_jj_command_args, record_jj_command_history, run_jj_command_in_path, +}; pub use repo::{ COMMIT_MESSAGE_PROMPT, DEFAULT_REVSET, DEFAULT_REVSET_DEPTH, Repo, build_default_revset, check_gh_environment, check_jj_environment, detect_ai_provider, find_existing_binary, diff --git a/crates/jayjay-core/src/repo/diffedit.rs b/crates/jayjay-core/src/repo/diffedit.rs index 1c25934..6f0f331 100644 --- a/crates/jayjay-core/src/repo/diffedit.rs +++ b/crates/jayjay-core/src/repo/diffedit.rs @@ -40,29 +40,20 @@ impl Repo { ignore_whitespace: bool, ) -> CoreResult<()> { match destination { - DiffEditDestination::RemoveFromSource => self.remove_diff_selection_from_source( + DiffEditDestination::RemoveFromSource => { + self.remove_diff_selection_from_source(rev, selections, ignore_whitespace) + } + DiffEditDestination::MoveToWorkingCopy => { + self.move_diff_selection_to_working_copy(rev, selections, ignore_whitespace) + } + DiffEditDestination::NewChild => self.extract_diff_selection_as_new_child( rev, selections, + message, ignore_whitespace, ), - DiffEditDestination::MoveToWorkingCopy => { - self.move_diff_selection_to_working_copy(rev, selections, ignore_whitespace) - } - DiffEditDestination::NewChild => { - self.extract_diff_selection_as_new_child( - rev, - selections, - message, - ignore_whitespace, - ) - } DiffEditDestination::NewParallel => { - self.extract_diff_selection_as_parallel( - rev, - selections, - message, - ignore_whitespace, - ) + self.extract_diff_selection_as_parallel(rev, selections, message, ignore_whitespace) } } } @@ -158,7 +149,10 @@ impl Repo { )?; let rewritten_source = block_on_result( "rewrite source commit", - repo_mut.rewrite_commit(commit).set_tree(remaining_tree).write(), + repo_mut + .rewrite_commit(commit) + .set_tree(remaining_tree) + .write(), )?; let child_tree = self.apply_selection_to_tree( &source_selection, @@ -204,7 +198,10 @@ impl Repo { block_on_result("rewrite source commit", write)?; let parallel_description = self.diffedit_message(message, commit); let write = repo_mut - .new_commit(commit.parent_ids().to_vec(), source_selection.selected_tree.clone()) + .new_commit( + commit.parent_ids().to_vec(), + source_selection.selected_tree.clone(), + ) .set_description(¶llel_description) .write(); block_on_result("create parallel change", write)?; @@ -334,9 +331,16 @@ impl Repo { ) -> CoreResult { let metadata = self .resolved_file_value(source_tree, path, "load selected file metadata")? - .or_else(|| self.resolved_file_value(parent_tree, path, "load parent file metadata").ok().flatten()) + .or_else(|| { + self.resolved_file_value(parent_tree, path, "load parent file metadata") + .ok() + .flatten() + }) .ok_or_else(|| CoreError::Internal { - message: format!("selected file metadata missing for {}", path.as_internal_file_string()), + message: format!( + "selected file metadata missing for {}", + path.as_internal_file_string() + ), })?; let TreeValue::File { @@ -372,7 +376,10 @@ impl Repo { ) -> CoreResult> { let value = block_on_result(context, tree.path_value(path))?; value.into_resolved().map_err(|_| CoreError::Internal { - message: format!("conflicted file values are not supported: {}", path.as_internal_file_string()), + message: format!( + "conflicted file values are not supported: {}", + path.as_internal_file_string() + ), }) } diff --git a/crates/jayjay-core/src/repo/github.rs b/crates/jayjay-core/src/repo/github.rs index b5cbfa2..3a7167a 100644 --- a/crates/jayjay-core/src/repo/github.rs +++ b/crates/jayjay-core/src/repo/github.rs @@ -1,7 +1,7 @@ use serde::Deserialize; -use super::environment::gh_binary; use super::Repo; +use super::environment::gh_binary; use crate::types::{ChecksStatus, PrInfo, PrState}; const GITHUB_HOST: &str = "github.com"; @@ -140,7 +140,10 @@ fn parse_gh_pr_json(json: &str) -> Option { let checks = match resp.status_check_rollup.as_slice() { [] => ChecksStatus::None, runs if runs.iter().any(|c| c.status != GhCheckStatus::Completed) => ChecksStatus::Pending, - runs if runs.iter().all(|c| c.conclusion == GhCheckConclusion::Success) => { + runs if runs + .iter() + .all(|c| c.conclusion == GhCheckConclusion::Success) => + { ChecksStatus::Passing } _ => ChecksStatus::Failing, @@ -189,11 +192,23 @@ mod tests { #[test] fn slug_extracts_from_common_remote_forms() { let expected = Some("hewigovens/jayjay"); - assert_eq!(github_slug("git@github.com:hewigovens/jayjay.git").as_deref(), expected); - assert_eq!(github_slug("https://github.com/hewigovens/jayjay.git\n").as_deref(), expected); - assert_eq!(github_slug("ssh://git@github.com/hewigovens/jayjay.git").as_deref(), expected); + assert_eq!( + github_slug("git@github.com:hewigovens/jayjay.git").as_deref(), + expected + ); + assert_eq!( + github_slug("https://github.com/hewigovens/jayjay.git\n").as_deref(), + expected + ); + assert_eq!( + github_slug("ssh://git@github.com/hewigovens/jayjay.git").as_deref(), + expected + ); // HTTPS with token/userinfo (e.g., from `gh auth setup-git`). - assert_eq!(github_slug("https://TOKEN@github.com/hewigovens/jayjay.git").as_deref(), expected); + assert_eq!( + github_slug("https://TOKEN@github.com/hewigovens/jayjay.git").as_deref(), + expected + ); } #[test] @@ -205,9 +220,15 @@ mod tests { #[test] fn slug_rejects_non_github_and_malformed() { // Lookalike hosts must not pass — opening a bogus PR URL is the failure mode here. - assert_eq!(github_slug("https://github.com.evil.org/hewigovens/jayjay"), None); + assert_eq!( + github_slug("https://github.com.evil.org/hewigovens/jayjay"), + None + ); assert_eq!(github_slug("https://evilgithub.com/foo/bar"), None); - assert_eq!(github_slug("https://gitlab.com/hewigovens/jayjay.git"), None); + assert_eq!( + github_slug("https://gitlab.com/hewigovens/jayjay.git"), + None + ); assert_eq!(github_slug("https://github.com/lonely"), None); assert_eq!(github_slug(""), None); } @@ -222,6 +243,9 @@ mod tests { {"name": "deploy", "status": "IN_PROGRESS"} ] }"#; - assert_eq!(parse_gh_pr_json(json).unwrap().checks, ChecksStatus::Pending); + assert_eq!( + parse_gh_pr_json(json).unwrap().checks, + ChecksStatus::Pending + ); } } diff --git a/crates/jayjay-core/src/repo/mutations.rs b/crates/jayjay-core/src/repo/mutations.rs index 385116c..c2add59 100644 --- a/crates/jayjay-core/src/repo/mutations.rs +++ b/crates/jayjay-core/src/repo/mutations.rs @@ -112,6 +112,27 @@ impl Repo { }) } + pub fn rebase_with_placement( + &self, + rev: &str, + dest: &str, + placement: RebasePlacement, + ) -> CoreResult<()> { + match placement { + RebasePlacement::Onto => self.rebase(rev, dest), + RebasePlacement::After => self.rebase_insert_after(rev, dest), + RebasePlacement::Before => self.rebase_insert_before(rev, dest), + } + } + + pub fn rebase_insert_after(&self, rev: &str, dest: &str) -> CoreResult<()> { + self.run_jj_reload(&["rebase", "-r", rev, "--insert-after", dest]) + } + + pub fn rebase_insert_before(&self, rev: &str, dest: &str) -> CoreResult<()> { + self.run_jj_reload(&["rebase", "-r", rev, "--insert-before", dest]) + } + /// Cherry-pick a revision into the current working copy (`jj graft`). pub fn graft(&self, rev: &str) -> CoreResult<()> { self.run_jj_reload(&["graft", "-r", rev]) @@ -134,9 +155,9 @@ impl Repo { self.run_jj_reload(&["absorb", "--from", rev]) } - /// Create a new change that inverts the diff of a prior change (`jj revert`). - pub fn backout(&self, rev: &str) -> CoreResult<()> { - self.run_jj_reload(&["revert", "-r", rev, "--insert-after", rev]) + /// Create a new change that inverts the diff of a prior change on top of `@` (`jj revert`). + pub fn revert_change(&self, rev: &str) -> CoreResult<()> { + self.run_jj_reload(&["revert", "-r", rev, "--onto", "@"]) } /// Split selected files out of a change into a new change. diff --git a/crates/jayjay-core/src/repo/mutations_files.rs b/crates/jayjay-core/src/repo/mutations_files.rs index 42f63c3..aa926b3 100644 --- a/crates/jayjay-core/src/repo/mutations_files.rs +++ b/crates/jayjay-core/src/repo/mutations_files.rs @@ -44,6 +44,10 @@ impl Repo { } } + pub fn restore_revision_into_working_copy(&self, rev: &str) -> CoreResult<()> { + self.run_jj_reload(&["restore", "--from", rev, "--into", "@"]) + } + /// Delete files from disk (working copy only). jj will pick up the deletion on next snapshot. pub fn delete_files(&self, paths: &[String]) -> CoreResult<()> { for path in paths { diff --git a/crates/jayjay-core/src/revsets.rs b/crates/jayjay-core/src/revsets.rs new file mode 100644 index 0000000..a75c451 --- /dev/null +++ b/crates/jayjay-core/src/revsets.rs @@ -0,0 +1,150 @@ +use serde_json::Value; + +use crate::{SavedRevset, hash::hex_sha256}; + +pub fn built_in_revsets() -> Vec { + [ + ("builtin:all", "All", "all()"), + ("builtin:mine", "Mine", "mine()"), + ("builtin:bookmarks", "Bookmarks", "bookmarks()"), + ("builtin:trunk", "Trunk", "trunk()"), + ("builtin:conflicts", "Conflicts", "conflicts()"), + ("builtin:heads", "Heads (No Children)", "heads(all())"), + ( + "builtin:local-stack", + "Local Stack", + "reachable(@, mutable())", + ), + ( + "builtin:current-dir", + "Touching Current Directory", + "files(\".\")", + ), + ("builtin:fork-point", "Fork Point of @", "fork_point(@)"), + ( + "builtin:empty-mutable", + "Empty Mutable", + "empty() & mutable()", + ), + ] + .into_iter() + .map(|(id, name, expression)| SavedRevset { + id: id.to_owned(), + name: name.to_owned(), + expression: expression.to_owned(), + }) + .collect() +} + +pub fn decode_saved_revsets_json(json: &str) -> Vec { + serde_json::from_str::>(json).unwrap_or_else(|_| { + serde_json::from_str::>(json) + .unwrap_or_default() + .into_iter() + .filter_map(|value| serde_json::from_value(value).ok()) + .collect() + }) +} + +pub fn encode_saved_revsets_json(revsets: &[SavedRevset]) -> String { + serde_json::to_string(revsets).unwrap_or_else(|_| "[]".to_owned()) +} + +pub fn upsert_saved_revset( + mut existing: Vec, + name: &str, + expression: &str, +) -> Vec { + let name = name.trim(); + let expression = expression.trim(); + if name.is_empty() || expression.is_empty() { + return existing; + } + + existing.retain(|item| { + !item.name.eq_ignore_ascii_case(name) && item.expression.as_str() != expression + }); + existing.insert( + 0, + SavedRevset { + id: saved_revset_id(name, expression), + name: name.to_owned(), + expression: expression.to_owned(), + }, + ); + existing +} + +pub fn remove_saved_revset(existing: Vec, id: &str) -> Vec { + existing.into_iter().filter(|item| item.id != id).collect() +} + +fn saved_revset_id(name: &str, expression: &str) -> String { + let hash = hex_sha256(format!("{name}\0{expression}").as_bytes()); + format!("saved:{}", &hash[..16]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_ins_include_conflicts() { + assert!( + built_in_revsets() + .iter() + .any(|item| item.expression == "conflicts()") + ); + } + + #[test] + fn upsert_trims_and_dedupes_name_or_expression() { + let existing = vec![ + SavedRevset { + id: "a".to_owned(), + name: "Mine".to_owned(), + expression: "mine()".to_owned(), + }, + SavedRevset { + id: "b".to_owned(), + name: "Other".to_owned(), + expression: "all()".to_owned(), + }, + ]; + + let saved = upsert_saved_revset(existing, " mine ", " all() "); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].name, "mine"); + assert_eq!(saved[0].expression, "all()"); + assert!(saved[0].id.starts_with("saved:")); + } + + #[test] + fn upsert_ignores_empty_name_or_expression() { + let existing = vec![SavedRevset { + id: "a".to_owned(), + name: "Mine".to_owned(), + expression: "mine()".to_owned(), + }]; + + assert_eq!( + upsert_saved_revset(existing.clone(), " ", "all()"), + existing + ); + assert_eq!( + upsert_saved_revset(existing.clone(), "All", " \n\t "), + existing + ); + } + + #[test] + fn json_round_trip_preserves_ids() { + let revsets = vec![SavedRevset { + id: "uuid-or-stable-id".to_owned(), + name: "Stack".to_owned(), + expression: "reachable(@, mutable())".to_owned(), + }]; + let json = encode_saved_revsets_json(&revsets); + assert_eq!(decode_saved_revsets_json(&json), revsets); + } +} diff --git a/crates/jayjay-core/src/types/change.rs b/crates/jayjay-core/src/types/change.rs index 3a9ba60..000fc5d 100644 --- a/crates/jayjay-core/src/types/change.rs +++ b/crates/jayjay-core/src/types/change.rs @@ -16,7 +16,7 @@ pub struct ChangeInfo { } /// One entry in a change's evolution history (one rewrite operation). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EvologEntry { pub change_id: String, pub commit_id: String, @@ -28,6 +28,27 @@ pub struct EvologEntry { pub description: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvologOperationKind { + Snapshot, + Describe, + Rebase, + Squash, + Split, + New, + Rewrite, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvologVisibleRow { + pub id: String, + pub primary_index: u32, + pub indices: Vec, + pub entries: Vec, + pub is_snapshot_run: bool, +} + /// A change with its graph edges for DAG rendering. #[derive(Debug, Clone)] pub struct GraphEntry { diff --git a/crates/jayjay-core/src/types/command.rs b/crates/jayjay-core/src/types/command.rs new file mode 100644 index 0000000..19da87a --- /dev/null +++ b/crates/jayjay-core/src/types/command.rs @@ -0,0 +1,9 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JjCommandRun { + pub display: String, + pub stdout: String, + pub stderr: String, + pub output: String, + pub exit_code: i32, + pub success: bool, +} diff --git a/crates/jayjay-core/src/types/mod.rs b/crates/jayjay-core/src/types/mod.rs index 1ad64da..94bf559 100644 --- a/crates/jayjay-core/src/types/mod.rs +++ b/crates/jayjay-core/src/types/mod.rs @@ -1,15 +1,21 @@ mod bookmark; mod change; +mod command; mod diff; mod diffedit; mod error; mod git; mod ops; +mod rebase; +mod revset; pub use bookmark::*; pub use change::*; +pub use command::*; pub use diff::*; pub use diffedit::*; pub use error::*; pub use git::*; pub use ops::*; +pub use rebase::*; +pub use revset::*; diff --git a/crates/jayjay-core/src/types/rebase.rs b/crates/jayjay-core/src/types/rebase.rs new file mode 100644 index 0000000..0d96c8e --- /dev/null +++ b/crates/jayjay-core/src/types/rebase.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RebasePlacement { + Onto, + After, + Before, +} diff --git a/crates/jayjay-core/src/types/revset.rs b/crates/jayjay-core/src/types/revset.rs new file mode 100644 index 0000000..bd8fcd1 --- /dev/null +++ b/crates/jayjay-core/src/types/revset.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedRevset { + pub id: String, + pub name: String, + pub expression: String, +} diff --git a/crates/jayjay-core/tests/real_jj_repo.rs b/crates/jayjay-core/tests/real_jj_repo.rs index 32e31aa..793b4e1 100644 --- a/crates/jayjay-core/tests/real_jj_repo.rs +++ b/crates/jayjay-core/tests/real_jj_repo.rs @@ -460,7 +460,7 @@ fn image_file_is_cached_and_surfaced_as_diff_preview() { } #[test] -fn backout_uses_jj_revert_and_creates_reverse_change() { +fn revert_change_uses_jj_revert_and_creates_reverse_change() { if !jj_is_available() { eprintln!("skipping real jj repo test because `jj` is not installed"); return; @@ -472,17 +472,22 @@ fn backout_uses_jj_revert_and_creates_reverse_change() { repo.new_change("@", "child change") .expect("create child working copy"); - repo.backout("@-").expect("revert parent change"); + repo.revert_change("@-").expect("revert parent change"); - let reverted = repo.show("@-").expect("show reverted parent"); + let current = repo.show("@").expect("show unchanged working copy"); + assert_eq!(current.info.description.trim(), "child change"); + + let changes = repo.log("all()").expect("log changes"); + let reverted = changes + .iter() + .find(|change| change.description.contains("Revert")) + .expect("revert change"); assert!( - reverted.info.description.contains("Revert"), + reverted.description.contains("Revert"), "expected revert description, got {:?}", - reverted.info.description + reverted.description ); - - let current = repo.show("@").expect("show rebased working copy"); - assert_eq!(current.info.description.trim(), "child change"); + assert_eq!(reverted.parents, vec![current.info.commit_id]); } #[test] diff --git a/crates/jayjay-uniffi/src/repo.rs b/crates/jayjay-uniffi/src/repo.rs index ec6be1d..70cdfcd 100644 --- a/crates/jayjay-uniffi/src/repo.rs +++ b/crates/jayjay-uniffi/src/repo.rs @@ -41,6 +41,129 @@ pub fn check_gh_environment() -> core::CliStatus { core::check_gh_environment() } +#[uniffi::export] +pub fn built_in_revsets() -> Vec { + core::revsets::built_in_revsets() +} + +#[uniffi::export] +pub fn decode_saved_revsets_json(json: String) -> Vec { + core::revsets::decode_saved_revsets_json(&json) +} + +#[uniffi::export] +pub fn encode_saved_revsets_json(revsets: Vec) -> String { + core::revsets::encode_saved_revsets_json(&revsets) +} + +#[uniffi::export] +pub fn upsert_saved_revset( + existing: Vec, + name: String, + expression: String, +) -> Vec { + core::revsets::upsert_saved_revset(existing, &name, &expression) +} + +#[uniffi::export] +pub fn remove_saved_revset(existing: Vec, id: String) -> Vec { + core::revsets::remove_saved_revset(existing, &id) +} + +#[uniffi::export] +pub fn jj_command_body(query: String) -> Option { + core::jj_command_body(&query) +} + +#[uniffi::export] +pub fn parse_jj_command_args(command: String) -> Option> { + core::parse_jj_command_args(&command) +} + +#[uniffi::export] +pub fn record_jj_command_history( + command: String, + existing: Vec, + limit: u32, +) -> Vec { + core::record_jj_command_history(&command, &existing, limit as usize) +} + +#[uniffi::export] +pub fn run_jj_command_in_repo_path( + repo_path: String, + command: String, +) -> Result { + Ok(core::run_jj_command_in_path( + &PathBuf::from(repo_path), + &command, + )?) +} + +#[uniffi::export] +pub fn evolog_operation_kind(raw: String) -> core::EvologOperationKind { + core::evolog_display::evolog_operation_kind(&raw) +} + +#[uniffi::export] +pub fn evolog_visible_rows( + entries: Vec, + hide_snapshots: bool, + collapse_snapshot_runs: bool, +) -> Vec { + core::evolog_display::evolog_visible_rows(&entries, hide_snapshots, collapse_snapshot_runs) +} + +#[uniffi::export] +pub fn diff_edit_changed_lines(diff: core::diff::FileDiff) -> Vec { + core::diffedit_plan::diff_edit_changed_lines(&diff) +} + +#[uniffi::export] +pub fn diff_edit_supports_file( + hunk_type: core::HunkType, + old_content: Option, + new_content: Option, +) -> bool { + core::diffedit_plan::diff_edit_supports_file( + hunk_type, + old_content.as_deref(), + new_content.as_deref(), + ) +} + +#[uniffi::export] +pub fn diff_edit_unsupported_reason( + hunk_type: core::HunkType, + old_content: Option, + new_content: Option, +) -> Option { + core::diffedit_plan::diff_edit_unsupported_reason( + hunk_type, + old_content.as_deref(), + new_content.as_deref(), + ) +} + +#[uniffi::export] +pub fn build_diff_edit_file_selection( + hunk: core::DiffHunk, + diff: core::diff::FileDiff, + old_content: Option, + new_content: Option, + selected_lines: Vec, + inverse: bool, +) -> Option { + core::diffedit_plan::build_diff_edit_file_selection( + &hunk, + &diff, + old_content, + new_content, + &selected_lines, + inverse, + ) +} + /// Resolve a CLI binary by walking the same fallback paths jj does. Returns /// the absolute path when found, `nil` otherwise. macOS `.app` bundles get /// stripped PATH from launchd, so this avoids relying on shell PATH. @@ -245,6 +368,10 @@ impl JayJayRepo { Ok(self.inner.restore_files(&rev, &paths)?) } + pub fn restore_revision_into_working_copy(&self, rev: String) -> Result<(), JayJayError> { + Ok(self.inner.restore_revision_into_working_copy(&rev)?) + } + pub fn move_to_working_copy(&self, rev: String, paths: Vec) -> Result<(), JayJayError> { Ok(self.inner.move_to_working_copy(&rev, &paths)?) } @@ -291,8 +418,8 @@ impl JayJayRepo { Ok(self.inner.absorb(&rev)?) } - pub fn backout(&self, rev: String) -> Result<(), JayJayError> { - Ok(self.inner.backout(&rev)?) + pub fn revert_change(&self, rev: String) -> Result<(), JayJayError> { + Ok(self.inner.revert_change(&rev)?) } pub fn merge(&self, parent_revs: Vec) -> Result<(), JayJayError> { @@ -311,6 +438,23 @@ impl JayJayRepo { Ok(self.inner.rebase(&rev, &dest)?) } + pub fn rebase_with_placement( + &self, + rev: String, + dest: String, + placement: core::RebasePlacement, + ) -> Result<(), JayJayError> { + Ok(self.inner.rebase_with_placement(&rev, &dest, placement)?) + } + + pub fn rebase_insert_after(&self, rev: String, dest: String) -> Result<(), JayJayError> { + Ok(self.inner.rebase_insert_after(&rev, &dest)?) + } + + pub fn rebase_insert_before(&self, rev: String, dest: String) -> Result<(), JayJayError> { + Ok(self.inner.rebase_insert_before(&rev, &dest)?) + } + pub fn list_bookmarks(&self) -> Result, JayJayError> { Ok(self.inner.list_bookmarks()?) } diff --git a/crates/jayjay-uniffi/src/types.rs b/crates/jayjay-uniffi/src/types.rs index dfbceb8..a8fd93b 100644 --- a/crates/jayjay-uniffi/src/types.rs +++ b/crates/jayjay-uniffi/src/types.rs @@ -6,8 +6,9 @@ use jayjay_core::syntax::SyntaxToken; use jayjay_core::{ AnnotationLine, BookmarkInfo, ChangeDetail, ChangeInfo, ChecksStatus, CliStatus, DiffEditDestination, DiffEditFileSelection, DiffEditRange, DiffHunk, DiffPreview, DiffStats, - EdgeType, EvologEntry, FetchResult, FileTreeEntry, GitSubmoduleStatus, GraphEdge, GraphEntry, - HunkType, OpLogEntry, PrInfo, PrState, WorkspaceInfo, + EdgeType, EvologEntry, EvologOperationKind, EvologVisibleRow, FetchResult, FileTreeEntry, + GitSubmoduleStatus, GraphEdge, GraphEntry, HunkType, JjCommandRun, OpLogEntry, PrInfo, PrState, + RebasePlacement, SavedRevset, WorkspaceInfo, }; // --- All types use uniffi::remote — no wrapper structs or From impls --- @@ -21,6 +22,51 @@ pub struct EvologEntry { pub description: String, } +#[uniffi::remote(Enum)] +pub enum EvologOperationKind { + Snapshot, + Describe, + Rebase, + Squash, + Split, + New, + Rewrite, + Other, +} + +#[uniffi::remote(Record)] +pub struct EvologVisibleRow { + pub id: String, + pub primary_index: u32, + pub indices: Vec, + pub entries: Vec, + pub is_snapshot_run: bool, +} + +#[uniffi::remote(Record)] +pub struct SavedRevset { + pub id: String, + pub name: String, + pub expression: String, +} + +#[uniffi::remote(Record)] +pub struct JjCommandRun { + pub display: String, + pub stdout: String, + pub stderr: String, + pub output: String, + pub exit_code: i32, + pub success: bool, +} + +#[uniffi::remote(Enum)] +pub enum RebasePlacement { + Onto, + After, + Before, +} + #[uniffi::remote(Record)] pub struct ChangeInfo { pub change_id: String, diff --git a/crates/jj-diff/README.md b/crates/jj-diff/README.md index 21b57d3..ad50529 100644 --- a/crates/jj-diff/README.md +++ b/crates/jj-diff/README.md @@ -1,15 +1,15 @@ # jj-diff -Fast diff engine with Myers algorithm, syntax highlighting, and context collapsing. Extracted from [JayJay](https://github.com/hewigovens/jayjay). +Fast diff engine with histogram line matching, syntax highlighting, and context collapsing. Extracted from [JayJay](https://github.com/hewigovens/jayjay). **Zero dependency on jj-lib** — usable in any Rust project that needs diff rendering. ## Features -- **Myers line diff** via `similar` — O(n*d), same algorithm as libgit2/GitHub Desktop +- **Histogram line diff** via `similar` — matches `jj diff` and reads well on code - **Word-level diff** highlighting within changed lines - **Syntax highlighting** — tree-sitter with 18 languages -- **Context collapsing** with display-to-full line index mapping +- **Context collapsing** with display-to-full line index mapping and tiny-gap auto-expansion - **Side-by-side row building** — pairs removed/added lines for two-column rendering - **Placeholder detection** — Git LFS, submodule, binary file detection - **Skip highlighting** for `.lock`/`.csv`/`.svg` files @@ -55,7 +55,7 @@ Bash, C, C++, CSS, Go, HTML, Java, JavaScript, JSON, Markdown, Python, Ruby, Rus Everything needed to compute and structure diffs: -- Diff computation (Myers algorithm) +- Diff computation (histogram algorithm) - Word-level diff within lines - Syntax highlighting (tree-sitter) - Context collapsing with index mapping diff --git a/crates/jj-diff/src/compute.rs b/crates/jj-diff/src/compute.rs index a3ad3e1..5e30779 100644 --- a/crates/jj-diff/src/compute.rs +++ b/crates/jj-diff/src/compute.rs @@ -9,7 +9,12 @@ pub fn compute_file_diff(path: &str, old: &str, new: &str, ignore_whitespace: bo compute_file_diff_impl(path, old, new, ignore_whitespace, true) } -pub fn compute_file_diff_full(path: &str, old: &str, new: &str, ignore_whitespace: bool) -> FileDiff { +pub fn compute_file_diff_full( + path: &str, + old: &str, + new: &str, + ignore_whitespace: bool, +) -> FileDiff { compute_file_diff_impl(path, old, new, ignore_whitespace, false) } diff --git a/crates/jj-diff/src/context.rs b/crates/jj-diff/src/context.rs index 83c2f02..127109b 100644 --- a/crates/jj-diff/src/context.rs +++ b/crates/jj-diff/src/context.rs @@ -4,6 +4,8 @@ use super::types::{ CONTEXT_LINES, CollapsedDiff, DiffLine, DiffSpan, DiffSpanStyle, DisplayLineMapping, FileDiff, }; +const COLLAPSED_CONTEXT_THRESHOLD: usize = 2; + /// Collapse long runs of context lines, keeping only CONTEXT_LINES around changes. pub(super) fn collapse_context(lines: Vec) -> Vec { let full = FileDiff { @@ -17,7 +19,6 @@ pub(super) fn collapse_context(lines: Vec) -> Vec { /// Collapse context and return a mapping from display lines to full diff lines. pub fn collapse_context_with_mapping(full_diff: &FileDiff) -> CollapsedDiff { - let lines = &full_diff.lines; if lines.is_empty() { return CollapsedDiff { @@ -26,7 +27,8 @@ pub fn collapse_context_with_mapping(full_diff: &FileDiff) -> CollapsedDiff { }; } - let is_changed = |l: &DiffLine| l.style == DiffSpanStyle::Added || l.style == DiffSpanStyle::Removed; + let is_changed = + |l: &DiffLine| l.style == DiffSpanStyle::Added || l.style == DiffSpanStyle::Removed; let changed_indices: Vec = lines .iter() .enumerate() @@ -47,6 +49,20 @@ pub fn collapse_context_with_mapping(full_diff: &FileDiff) -> CollapsedDiff { display_to_full: mapping, }; } + let hidden = lines.len() - CONTEXT_LINES * 2; + if hidden <= COLLAPSED_CONTEXT_THRESHOLD { + let mapping = (0..lines.len()) + .map(|i| DisplayLineMapping { + display_line: (i + 1) as u32, + full_line: (i + 1) as u32, + }) + .collect(); + return CollapsedDiff { + diff: full_diff.clone(), + display_to_full: mapping, + }; + } + let mut result: Vec = lines[..CONTEXT_LINES].to_vec(); let mut mapping: Vec = (0..CONTEXT_LINES) .map(|i| DisplayLineMapping { @@ -54,7 +70,6 @@ pub fn collapse_context_with_mapping(full_diff: &FileDiff) -> CollapsedDiff { full_line: (i + 1) as u32, }) .collect(); - let hidden = lines.len() - CONTEXT_LINES * 2; result.push(separator_line(hidden)); // separator has no mapping entry for i in 0..CONTEXT_LINES { @@ -101,7 +116,18 @@ pub fn collapse_context_with_mapping(full_diff: &FileDiff) -> CollapsedDiff { i += 1; } let hidden = i - start; - if hidden > 0 { + if hidden == 0 { + continue; + } + if hidden <= COLLAPSED_CONTEXT_THRESHOLD { + for (offset, line) in lines[start..i].iter().enumerate() { + result.push(line.clone()); + mapping.push(DisplayLineMapping { + display_line: result.len() as u32, + full_line: (start + offset + 1) as u32, + }); + } + } else { result.push(separator_line(hidden)); } } @@ -124,7 +150,7 @@ pub(super) fn separator_line(hidden_count: usize) -> DiffLine { new_line_no: None, style: DiffSpanStyle::Separator, spans: vec![DiffSpan { - text: format!("{hidden_count} hidden lines"), + text: format!("{hidden_count} unmodified lines"), style: DiffSpanStyle::Separator, token: SyntaxToken::Plain, }], diff --git a/crates/jj-diff/src/tests.rs b/crates/jj-diff/src/tests.rs index fe7fd6b..971b26a 100644 --- a/crates/jj-diff/src/tests.rs +++ b/crates/jj-diff/src/tests.rs @@ -182,6 +182,33 @@ fn test_context_collapsing() { ); } +#[test] +fn test_context_collapsing_keeps_tiny_gap_between_hunks() { + let old_lines: Vec = (1..=14).map(|i| format!("line {i}")).collect(); + let mut new_lines = old_lines.clone(); + new_lines[3] = "CHANGED 4".to_string(); + new_lines[11] = "CHANGED 12".to_string(); + + let old = old_lines.join("\n") + "\n"; + let new = new_lines.join("\n") + "\n"; + let full = compute_file_diff_full("test.txt", &old, &new, false); + let collapsed = collapse_context_with_mapping(&full); + + assert_eq!( + collapsed.diff.lines.len(), + full.lines.len(), + "a one-line context gap is clearer inline than behind a separator" + ); + assert!( + collapsed + .diff + .lines + .iter() + .all(|l| l.style != DiffSpanStyle::Separator), + "tiny context gaps should not be collapsed" + ); +} + // ── Word-level diff highlighting tests ────────────────────────── /// Helper: collect (text, style) pairs from spans of a DiffLine. @@ -259,11 +286,13 @@ fn test_word_diff_prefix_change() { // Both lines should have some changed content assert!( - rem.iter().any(|(_, s)| *s == DiffSpanStyle::Removed || *s == DiffSpanStyle::Unchanged), + rem.iter() + .any(|(_, s)| *s == DiffSpanStyle::Removed || *s == DiffSpanStyle::Unchanged), "removed line should have word-level spans, got: {rem:?}" ); assert!( - add.iter().any(|(_, s)| *s == DiffSpanStyle::Added || *s == DiffSpanStyle::Unchanged), + add.iter() + .any(|(_, s)| *s == DiffSpanStyle::Added || *s == DiffSpanStyle::Unchanged), "added line should have word-level spans, got: {add:?}" ); } diff --git a/crates/jj-diff/src/types.rs b/crates/jj-diff/src/types.rs index 1b48985..5b41f07 100644 --- a/crates/jj-diff/src/types.rs +++ b/crates/jj-diff/src/types.rs @@ -8,7 +8,7 @@ pub enum DiffSpanStyle { Added, Removed, Unchanged, - /// Collapsed region placeholder — `spans[0].text` contains "N hidden lines". + /// Collapsed region placeholder — `spans[0].text` contains "N unmodified lines". Separator, } diff --git a/crates/jj-test-fixtures/src/cmd.rs b/crates/jj-test-fixtures/src/cmd.rs index caf2dd9..319d1dc 100644 --- a/crates/jj-test-fixtures/src/cmd.rs +++ b/crates/jj-test-fixtures/src/cmd.rs @@ -17,10 +17,7 @@ pub fn init_colocated(path: &Path) { /// Set a deterministic test identity so commit hashes are reproducible. pub fn configure_test_user(repo: &Path) { - run_jj_in( - repo, - &["config", "set", "--repo", "user.name", "Test User"], - ); + run_jj_in(repo, &["config", "set", "--repo", "user.name", "Test User"]); run_jj_in( repo, &["config", "set", "--repo", "user.email", "test@example.com"], diff --git a/docs/index.html b/docs/index.html index 2f450cb..2e62bc4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1077,7 +1077,7 @@

Everything you need for jj

All jj Operations
-

New, edit, squash, split, graft, duplicate, merge, rebase, absorb, backout, abandon. Git push/pull with auto-track. Multi-window support.

+

New, edit, squash, split, graft, duplicate, merge, rebase, absorb, revert, abandon. Git push/pull with auto-track. Multi-window support.

diff --git a/shell/gpui/src/diff/annotate_view.rs b/shell/gpui/src/diff/annotate_view.rs index b7a55dd..fb5fd76 100644 --- a/shell/gpui/src/diff/annotate_view.rs +++ b/shell/gpui/src/diff/annotate_view.rs @@ -7,8 +7,8 @@ use gpui::{ use jayjay_core::AnnotationLine; use crate::app::fonts; -use crate::ui::primitives::no_scrollbar_gutter; use crate::app::theme::{ANNOTATE_PALETTE, Theme}; +use crate::ui::primitives::no_scrollbar_gutter; fn change_color(change_id: &str) -> u32 { let bytes = change_id.as_bytes(); @@ -34,9 +34,7 @@ pub fn annotate_body( }, ) .track_scroll(&scroll); - no_scrollbar_gutter(list) - .h_full() - .into_any_element() + no_scrollbar_gutter(list).h_full().into_any_element() } fn annotate_row(line: &AnnotationLine, t: &Theme) -> AnyElement { diff --git a/shell/gpui/src/diff/diff_view/mouse.rs b/shell/gpui/src/diff/diff_view/mouse.rs index 78431fe..e8d9a72 100644 --- a/shell/gpui/src/diff/diff_view/mouse.rs +++ b/shell/gpui/src/diff/diff_view/mouse.rs @@ -9,8 +9,11 @@ use crate::log::{LogView, PanelBoundsSlot}; // Absolute overlay canvas — captures parent bounds during prepaint. pub(super) fn bounds_capture(slot: PanelBoundsSlot) -> impl IntoElement { canvas( - move |bounds, _window, _cx| { - slot.set(Some(bounds)); + move |bounds, window, _cx| { + if slot.get() != Some(bounds) { + slot.set(Some(bounds)); + window.refresh(); + } }, |_, _, _, _| {}, ) @@ -33,6 +36,7 @@ pub(super) fn attach_selection_handlers( ix: usize, side: SbsSide, advance: Pixels, + col_offset: usize, bounds: PanelBoundsSlot, cx: &mut Context, ) -> E @@ -43,7 +47,7 @@ where elem.on_mouse_down( MouseButton::Left, cx.listener(move |v, ev: &MouseDownEvent, _, cx| { - let col = pixel_to_col(&down_bounds, ev.position.x, advance); + let col = col_offset + pixel_to_col(&down_bounds, ev.position.x, advance); if ev.click_count >= 2 { v.select_word(ix, col, side, cx); } else { @@ -52,7 +56,7 @@ where }), ) .on_mouse_move(cx.listener(move |v, ev: &MouseMoveEvent, _, cx| { - let col = pixel_to_col(&bounds, ev.position.x, advance); + let col = col_offset + pixel_to_col(&bounds, ev.position.x, advance); v.extend_diff_selection(ix, col, side, cx); })) } diff --git a/shell/gpui/src/diff/diff_view/sbs_body.rs b/shell/gpui/src/diff/diff_view/sbs_body.rs index 07c7cd1..9c6d1f2 100644 --- a/shell/gpui/src/diff/diff_view/sbs_body.rs +++ b/shell/gpui/src/diff/diff_view/sbs_body.rs @@ -1,9 +1,8 @@ use std::sync::Arc; use gpui::{ - AnyElement, Context, InteractiveElement, IntoElement, MouseButton, MouseUpEvent, - ParentElement, Pixels, Styled, UniformList, UniformListScrollHandle, div, px, rgb, - uniform_list, + AnyElement, Context, InteractiveElement, IntoElement, MouseButton, MouseUpEvent, ParentElement, + Pixels, Styled, UniformList, UniformListScrollHandle, div, px, rgb, uniform_list, }; use jayjay_core::diff::FileDiff; use jayjay_core::diff::side_by_side::build_side_by_side_rows; @@ -15,6 +14,9 @@ use crate::diff::SbsSide; use crate::diff::side_by_side::{ SBS_GUTTER_WIDTH, sbs_new_content, sbs_new_gutter, sbs_old_content, sbs_old_gutter, }; +use crate::diff::wrap::{ + WrappedSbsRow, selection_cols_in_fragment, wrap_cols_from_bounds, wrap_sbs_rows, +}; use crate::log::{LogView, PanelBoundsSlot}; use crate::ui::primitives::no_scrollbar_gutter; @@ -27,11 +29,14 @@ pub(super) fn side_by_side_body( new_bounds: PanelBoundsSlot, cx: &mut Context, ) -> AnyElement { - let rows: Arc> = Arc::new(build_side_by_side_rows(&fd.lines)); - let count = rows.len(); let theme = Arc::new(theme); let query = Arc::new(query); let advance = fonts::mono_advance(cx, px(12.)); + let rows = build_side_by_side_rows(&fd.lines); + let old_cols = wrap_cols_from_bounds(old_bounds.get(), advance); + let new_cols = wrap_cols_from_bounds(new_bounds.get(), advance); + let rows: Arc> = Arc::new(wrap_sbs_rows(&rows, old_cols, new_cols)); + let count = rows.len(); let old_gutter = { let rows = rows.clone(); @@ -40,7 +45,9 @@ pub(super) fn side_by_side_body( "sbs-old-gutter", count, move |range: std::ops::Range, _window, _cx| { - range.map(|ix| sbs_old_gutter(&rows[ix], &theme)).collect() + range + .map(|ix| sbs_old_gutter(&rows[ix].row, &theme)) + .collect() }, ) .track_scroll(&scroll) @@ -52,7 +59,9 @@ pub(super) fn side_by_side_body( "sbs-new-gutter", count, move |range: std::ops::Range, _window, _cx| { - range.map(|ix| sbs_new_gutter(&rows[ix], &theme)).collect() + range + .map(|ix| sbs_new_gutter(&rows[ix].row, &theme)) + .collect() }, ) .track_scroll(&scroll) @@ -96,28 +105,27 @@ pub(super) fn side_by_side_body( .border_color(rgb(theme.border)) .child(no_scrollbar_gutter(list).h_full()) }; - let content_panel = |list: UniformList, - bounds: PanelBoundsSlot, - side: SbsSide, - cx: &mut Context| { - div() - .relative() - .flex_1() - .min_w_0() - .h_full() - .child(bounds_capture(bounds)) - .on_mouse_up( - MouseButton::Left, - cx.listener(move |v, _: &MouseUpEvent, _, cx| { - if v.diff.selection - .is_some_and(|s| s.side == side && s.dragging) - { - v.finish_diff_selection(cx); - } - }), - ) - .child(no_scrollbar_gutter(list).h_full()) - }; + let content_panel = + |list: UniformList, bounds: PanelBoundsSlot, side: SbsSide, cx: &mut Context| { + div() + .relative() + .flex_1() + .min_w_0() + .h_full() + .child(bounds_capture(bounds)) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |v, _: &MouseUpEvent, _, cx| { + if v.diff + .selection + .is_some_and(|s| s.side == side && s.dragging) + { + v.finish_diff_selection(cx); + } + }), + ) + .child(no_scrollbar_gutter(list).h_full()) + }; div() .flex() @@ -135,7 +143,7 @@ pub(super) fn side_by_side_body( struct SbsContentArgs { id: &'static str, count: usize, - rows: Arc>, + rows: Arc>, theme: Arc, query: Arc>, scroll: UniformListScrollHandle, @@ -164,23 +172,29 @@ fn sbs_content_list(args: SbsContentArgs, cx: &mut Context) -> UniformL range .map(|ix| { let row = &rows[ix]; - let spans = if matches!(side, SbsSide::Old) { - &row.old_spans + let line_len = if matches!(side, SbsSide::Old) { + row.old_line_len + } else { + row.new_line_len + }; + let (col_start, col_end) = if matches!(side, SbsSide::Old) { + (row.old_col_start, row.old_col_end) } else { - &row.new_spans + (row.new_col_start, row.new_col_end) }; - let line_len = spans.iter().map(|s| s.text.chars().count()).sum(); let selection_cols = sel.and_then(|s| { if s.side == side { - s.col_range_for(ix, line_len) + s.col_range_for(row.row_ix, line_len).and_then(|cols| { + selection_cols_in_fragment(cols, col_start, col_end) + }) } else { None } }); let cell = if matches!(side, SbsSide::Old) { - sbs_old_content(row, &theme, query.as_deref()) + sbs_old_content(&row.row, &theme, query.as_deref()) } else { - sbs_new_content(row, &theme, query.as_deref()) + sbs_new_content(&row.row, &theme, query.as_deref()) }; // Wrap so the absolute selection overlay has a relative parent. let cell = if let Some(cols) = selection_cols { @@ -190,14 +204,20 @@ fn sbs_content_list(args: SbsContentArgs, cx: &mut Context) -> UniformL .min_w_0() .h_full() .child(cell) - .child(crate::diff::line::selection_overlay( - cols, advance, &theme, - )) + .child(crate::diff::line::selection_overlay(cols, advance, &theme)) } else { div().flex_1().min_w_0().h_full().child(cell) }; - attach_selection_handlers(cell, ix, side, advance, bounds.clone(), cx) - .into_any_element() + attach_selection_handlers( + cell, + row.row_ix, + side, + advance, + col_start, + bounds.clone(), + cx, + ) + .into_any_element() }) .collect() }), diff --git a/shell/gpui/src/diff/diff_view/unified_body.rs b/shell/gpui/src/diff/diff_view/unified_body.rs index 8216ace..629184e 100644 --- a/shell/gpui/src/diff/diff_view/unified_body.rs +++ b/shell/gpui/src/diff/diff_view/unified_body.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use gpui::{ - AnyElement, Context, InteractiveElement, IntoElement, MouseButton, MouseUpEvent, - ParentElement, Styled, UniformListScrollHandle, div, px, rgb, uniform_list, + AnyElement, Context, InteractiveElement, IntoElement, MouseButton, MouseUpEvent, ParentElement, + Styled, UniformListScrollHandle, div, px, rgb, uniform_list, }; use jayjay_core::diff::FileDiff; @@ -11,6 +11,7 @@ use crate::app::fonts; use crate::app::theme::Theme; use crate::diff::SbsSide; use crate::diff::line::{GUTTER_WIDTH, content_row, gutter_row}; +use crate::diff::wrap::{selection_cols_in_fragment, wrap_cols_from_bounds, wrap_diff_lines}; use crate::log::{LogView, PanelBoundsSlot}; use crate::ui::primitives::no_scrollbar_gutter; @@ -22,11 +23,12 @@ pub(super) fn unified_body( bounds_slot: PanelBoundsSlot, cx: &mut Context, ) -> AnyElement { - let lines: Arc> = Arc::new(fd.lines.clone()); - let count = lines.len(); let theme = Arc::new(theme); let query = Arc::new(query); let advance = fonts::mono_advance(cx, px(12.)); + let wrap_cols = wrap_cols_from_bounds(bounds_slot.get(), advance); + let lines: Arc> = Arc::new(wrap_diff_lines(&fd.lines, wrap_cols)); + let count = lines.len(); let gutter_lines = lines.clone(); let gutter_theme = theme.clone(); @@ -35,7 +37,7 @@ pub(super) fn unified_body( count, move |range: std::ops::Range, _window, _cx| { range - .map(|ix| gutter_row(&gutter_lines[ix], &gutter_theme)) + .map(|ix| gutter_row(&gutter_lines[ix].line, &gutter_theme)) .collect() }, ) @@ -53,16 +55,18 @@ pub(super) fn unified_body( range .map(|ix| { let line = &content_lines[ix]; - let line_len = line.spans.iter().map(|s| s.text.chars().count()).sum(); let selection_cols = sel.and_then(|s| { if s.side == SbsSide::Unified { - s.col_range_for(ix, line_len) + s.col_range_for(line.line_ix, line.line_len) + .and_then(|cols| { + selection_cols_in_fragment(cols, line.col_start, line.col_end) + }) } else { None } }); let row = content_row( - line, + &line.line, &content_theme, content_query.as_deref(), selection_cols, @@ -70,9 +74,10 @@ pub(super) fn unified_body( ); attach_selection_handlers( row, - ix, + line.line_ix, SbsSide::Unified, advance, + line.col_start, content_bounds.clone(), cx, ) diff --git a/shell/gpui/src/diff/mod.rs b/shell/gpui/src/diff/mod.rs index 7f07d13..fe8bdf6 100644 --- a/shell/gpui/src/diff/mod.rs +++ b/shell/gpui/src/diff/mod.rs @@ -6,6 +6,7 @@ mod line; mod selection; mod side_by_side; mod spans; +pub(crate) mod wrap; pub use diff_view::{DetailMode, DiffViewMode, DiffViewState, FindState, diff_view}; pub use file_column::{FileColumnState, file_column}; diff --git a/shell/gpui/src/diff/selection.rs b/shell/gpui/src/diff/selection.rs index 8a43e7e..3da1a36 100644 --- a/shell/gpui/src/diff/selection.rs +++ b/shell/gpui/src/diff/selection.rs @@ -58,28 +58,31 @@ impl DiffSelection { if !self.line_range().contains(&line_ix) { return None; } - let (lo_line, lo_col, hi_line, hi_col) = if (self.anchor_line, self.anchor_col) - <= (self.focus_line, self.focus_col) - { - ( - self.anchor_line, - self.anchor_col, - self.focus_line, - self.focus_col, - ) - } else { - ( - self.focus_line, - self.focus_col, - self.anchor_line, - self.anchor_col, - ) - }; + let (lo_line, lo_col, hi_line, hi_col) = + if (self.anchor_line, self.anchor_col) <= (self.focus_line, self.focus_col) { + ( + self.anchor_line, + self.anchor_col, + self.focus_line, + self.focus_col, + ) + } else { + ( + self.focus_line, + self.focus_col, + self.anchor_line, + self.anchor_col, + ) + }; let start = if line_ix == lo_line { lo_col } else { 0 }; let end = if line_ix == hi_line { hi_col } else { line_len }; let start = start.min(line_len); let end = end.min(line_len); - if start >= end { Some(start..start) } else { Some(start..end) } + if start >= end { + Some(start..start) + } else { + Some(start..end) + } } } diff --git a/shell/gpui/src/diff/wrap.rs b/shell/gpui/src/diff/wrap.rs new file mode 100644 index 0000000..abdae9a --- /dev/null +++ b/shell/gpui/src/diff/wrap.rs @@ -0,0 +1,323 @@ +use std::ops::Range; + +use gpui::{Bounds, Pixels}; +use jayjay_core::diff::side_by_side::SideBySideRow; +use jayjay_core::diff::{DiffLine, DiffSpan, DiffSpanStyle}; + +const DEFAULT_WRAP_COLS: usize = 120; +const MIN_WRAP_COLS: usize = 24; + +#[derive(Clone)] +pub struct WrappedDiffLine { + pub line_ix: usize, + pub line_len: usize, + pub col_start: usize, + pub col_end: usize, + pub line: DiffLine, +} + +#[derive(Clone)] +pub struct WrappedSbsRow { + pub row_ix: usize, + pub old_line_len: usize, + pub old_col_start: usize, + pub old_col_end: usize, + pub new_line_len: usize, + pub new_col_start: usize, + pub new_col_end: usize, + pub row: SideBySideRow, +} + +pub fn wrap_cols_from_bounds(bounds: Option>, advance: Pixels) -> usize { + let Some(bounds) = bounds else { + return DEFAULT_WRAP_COLS; + }; + wrap_cols_for_width(f32::from(bounds.size.width), f32::from(advance)) +} + +pub fn wrap_cols_for_width(width: f32, advance: f32) -> usize { + if width <= 0. || advance <= 0. { + return DEFAULT_WRAP_COLS; + } + ((width / advance).floor() as usize) + .saturating_sub(1) + .max(MIN_WRAP_COLS) +} + +pub fn line_char_len(line: &DiffLine) -> usize { + spans_char_len(&line.spans) +} + +pub fn spans_char_len(spans: &[DiffSpan]) -> usize { + spans.iter().map(|span| span.text.chars().count()).sum() +} + +pub fn wrap_diff_lines(lines: &[DiffLine], cols: usize) -> Vec { + let cols = cols.max(1); + let mut wrapped = Vec::new(); + for (line_ix, line) in lines.iter().enumerate() { + let line_len = line_char_len(line); + if line.style == DiffSpanStyle::Separator || line_len <= cols { + wrapped.push(WrappedDiffLine { + line_ix, + line_len, + col_start: 0, + col_end: line_len, + line: line.clone(), + }); + continue; + } + + for (visual_ix, start) in (0..line_len).step_by(cols).enumerate() { + let end = (start + cols).min(line_len); + wrapped.push(WrappedDiffLine { + line_ix, + line_len, + col_start: start, + col_end: end, + line: DiffLine { + old_line_no: (visual_ix == 0).then_some(line.old_line_no).flatten(), + new_line_no: (visual_ix == 0).then_some(line.new_line_no).flatten(), + style: line.style, + spans: split_spans(&line.spans, start, end), + no_eof_newline: line.no_eof_newline && end == line_len, + }, + }); + } + } + wrapped +} + +pub fn wrap_sbs_rows( + rows: &[SideBySideRow], + old_cols: usize, + new_cols: usize, +) -> Vec { + let old_cols = old_cols.max(1); + let new_cols = new_cols.max(1); + let mut wrapped = Vec::new(); + + for (row_ix, row) in rows.iter().enumerate() { + let old_len = spans_char_len(&row.old_spans); + let new_len = spans_char_len(&row.new_spans); + if row.old_style == DiffSpanStyle::Separator { + wrapped.push(WrappedSbsRow { + row_ix, + old_line_len: old_len, + old_col_start: 0, + old_col_end: old_len, + new_line_len: new_len, + new_col_start: 0, + new_col_end: new_len, + row: row.clone(), + }); + continue; + } + + let old_chunks = side_chunks(&row.old_spans, old_cols); + let new_chunks = side_chunks(&row.new_spans, new_cols); + let visual_count = old_chunks.len().max(new_chunks.len()).max(1); + for visual_ix in 0..visual_count { + let old = old_chunks.get(visual_ix).cloned().unwrap_or_default(); + let new = new_chunks.get(visual_ix).cloned().unwrap_or_default(); + wrapped.push(WrappedSbsRow { + row_ix, + old_line_len: old_len, + old_col_start: old.start, + old_col_end: old.end, + new_line_len: new_len, + new_col_start: new.start, + new_col_end: new.end, + row: SideBySideRow { + old_line_no: if visual_ix == 0 { + row.old_line_no.clone() + } else { + String::new() + }, + old_spans: old.spans, + old_style: row.old_style, + new_line_no: if visual_ix == 0 { + row.new_line_no.clone() + } else { + String::new() + }, + new_spans: new.spans, + new_style: row.new_style, + }, + }); + } + } + + wrapped +} + +pub fn visual_index_for_line(wrapped: &[WrappedDiffLine], line_ix: usize) -> usize { + wrapped + .iter() + .position(|row| row.line_ix == line_ix) + .unwrap_or(line_ix) +} + +pub fn selection_cols_in_fragment( + cols: Range, + fragment_start: usize, + fragment_end: usize, +) -> Option> { + if cols.start == cols.end { + return (cols.start >= fragment_start && cols.start <= fragment_end) + .then_some((cols.start - fragment_start)..(cols.start - fragment_start)); + } + + let start = cols.start.max(fragment_start); + let end = cols.end.min(fragment_end); + (start < end).then_some((start - fragment_start)..(end - fragment_start)) +} + +#[derive(Clone, Default)] +struct SpanChunk { + start: usize, + end: usize, + spans: Vec, +} + +fn side_chunks(spans: &[DiffSpan], cols: usize) -> Vec { + let len = spans_char_len(spans); + if len == 0 { + return vec![SpanChunk::default()]; + } + (0..len) + .step_by(cols) + .map(|start| { + let end = (start + cols).min(len); + SpanChunk { + start, + end, + spans: split_spans(spans, start, end), + } + }) + .collect() +} + +fn split_spans(spans: &[DiffSpan], start: usize, end: usize) -> Vec { + let mut out = Vec::new(); + let mut cursor = 0usize; + for span in spans { + let span_len = span.text.chars().count(); + let span_start = cursor; + let span_end = span_start + span_len; + cursor = span_end; + + let overlap_start = start.max(span_start); + let overlap_end = end.min(span_end); + if overlap_start >= overlap_end { + continue; + } + + out.push(DiffSpan { + text: span + .text + .chars() + .skip(overlap_start - span_start) + .take(overlap_end - overlap_start) + .collect(), + style: span.style, + token: span.token, + }); + } + out +} + +#[cfg(test)] +mod tests { + use jayjay_core::diff::syntax::SyntaxToken; + + use super::*; + + #[test] + fn wraps_unified_line_with_blank_continuation_numbers() { + let line = diff_line("abcdefghij", Some(12), Some(14), DiffSpanStyle::Added); + let wrapped = wrap_diff_lines(&[line], 4); + + assert_eq!(wrapped.len(), 3); + assert_eq!(text(&wrapped[0].line.spans), "abcd"); + assert_eq!(text(&wrapped[1].line.spans), "efgh"); + assert_eq!(text(&wrapped[2].line.spans), "ij"); + assert_eq!(wrapped[0].line.new_line_no, Some(14)); + assert_eq!(wrapped[1].line.new_line_no, None); + assert_eq!(wrapped[2].col_start, 8); + assert_eq!(wrapped[2].col_end, 10); + } + + #[test] + fn split_preserves_span_styles() { + let spans = vec![ + span("abc", DiffSpanStyle::Unchanged), + span("def", DiffSpanStyle::Added), + ]; + + let split = split_spans(&spans, 2, 5); + + assert_eq!(split.len(), 2); + assert_eq!(split[0].text, "c"); + assert_eq!(split[0].style, DiffSpanStyle::Unchanged); + assert_eq!(split[1].text, "de"); + assert_eq!(split[1].style, DiffSpanStyle::Added); + } + + #[test] + fn wraps_side_by_side_to_tallest_side() { + let row = SideBySideRow { + old_line_no: "10".to_owned(), + old_spans: vec![span("abcdefgh", DiffSpanStyle::Removed)], + old_style: DiffSpanStyle::Removed, + new_line_no: "10".to_owned(), + new_spans: vec![span("wxyz", DiffSpanStyle::Added)], + new_style: DiffSpanStyle::Added, + }; + + let wrapped = wrap_sbs_rows(&[row], 3, 3); + + assert_eq!(wrapped.len(), 3); + assert_eq!(text(&wrapped[0].row.old_spans), "abc"); + assert_eq!(text(&wrapped[1].row.old_spans), "def"); + assert_eq!(text(&wrapped[2].row.old_spans), "gh"); + assert_eq!(text(&wrapped[0].row.new_spans), "wxy"); + assert_eq!(text(&wrapped[1].row.new_spans), "z"); + assert_eq!(text(&wrapped[2].row.new_spans), ""); + assert_eq!(wrapped[1].row.old_line_no, ""); + } + + #[test] + fn selection_is_mapped_relative_to_visual_fragment() { + assert_eq!(selection_cols_in_fragment(2..8, 4, 10), Some(0..4)); + assert_eq!(selection_cols_in_fragment(2..4, 4, 10), None); + assert_eq!(selection_cols_in_fragment(6..6, 4, 10), Some(2..2)); + } + + fn diff_line( + text: &str, + old_line_no: Option, + new_line_no: Option, + style: DiffSpanStyle, + ) -> DiffLine { + DiffLine { + old_line_no, + new_line_no, + style, + spans: vec![span(text, style)], + no_eof_newline: false, + } + } + + fn span(text: &str, style: DiffSpanStyle) -> DiffSpan { + DiffSpan { + text: text.to_owned(), + style, + token: SyntaxToken::Plain, + } + } + + fn text(spans: &[DiffSpan]) -> String { + spans.iter().map(|span| span.text.as_str()).collect() + } +} diff --git a/shell/gpui/src/log/detail/mod.rs b/shell/gpui/src/log/detail/mod.rs index 06b1fe5..ef6dc73 100644 --- a/shell/gpui/src/log/detail/mod.rs +++ b/shell/gpui/src/log/detail/mod.rs @@ -32,7 +32,8 @@ pub(super) fn detail_pane(view: &LogView, t: &Theme, cx: &mut Context) let loading_annotate = vm.loading.annotate; let current_diff = vm.current_diff.clone(); let selected_hunk = vm.selected_hunk().cloned(); - let path_just_copied = view.feedback.recently_copied.as_ref().map(|s| s.as_ref()) == Some("path"); + let path_just_copied = + view.feedback.recently_copied.as_ref().map(|s| s.as_ref()) == Some("path"); let diff_state = DiffViewState { hunk: selected_hunk.as_ref(), diff --git a/shell/gpui/src/log/diff_select.rs b/shell/gpui/src/log/diff_select.rs index 77ec914..981f121 100644 --- a/shell/gpui/src/log/diff_select.rs +++ b/shell/gpui/src/log/diff_select.rs @@ -98,7 +98,9 @@ impl LogView { match sel.side { SbsSide::Unified => { for ix in sel.line_range() { - let Some(line) = fd.lines.get(ix) else { continue }; + let Some(line) = fd.lines.get(ix) else { + continue; + }; let text: String = line.spans.iter().map(|s| s.text.as_str()).collect(); let n = text.chars().count(); if let Some(cols) = sel.col_range_for(ix, n) { diff --git a/shell/gpui/src/log/find.rs b/shell/gpui/src/log/find.rs index 2db97e6..0b79202 100644 --- a/shell/gpui/src/log/find.rs +++ b/shell/gpui/src/log/find.rs @@ -1,6 +1,9 @@ -use gpui::{App, Context, ScrollStrategy}; +use gpui::{App, Context, ScrollStrategy, px}; use super::LogView; +use crate::app::fonts; +use crate::diff::DiffViewMode; +use crate::diff::wrap::{visual_index_for_line, wrap_cols_from_bounds, wrap_diff_lines}; impl LogView { pub fn open_find(&mut self, cx: &mut Context) { @@ -39,10 +42,22 @@ impl LogView { } } - fn jump_to_current_match(&self) { + fn jump_to_current_match(&self, cx: &App) { if let Some(&line_ix) = self.find.matches.get(self.find.current) { - self.scrolls.diff - .scroll_to_item(line_ix, ScrollStrategy::Center); + let vm = self.vm.read(cx); + let item_ix = if vm.view_mode == DiffViewMode::Unified { + let advance = fonts::mono_advance(cx, px(12.)); + let cols = wrap_cols_from_bounds(self.diff.unified_bounds.get(), advance); + vm.current_diff + .as_ref() + .map(|diff| visual_index_for_line(&wrap_diff_lines(&diff.lines, cols), line_ix)) + .unwrap_or(line_ix) + } else { + line_ix + }; + self.scrolls + .diff + .scroll_to_item(item_ix, ScrollStrategy::Center); } } @@ -57,7 +72,7 @@ impl LogView { } else { self.find.current = (self.find.current + 1) % len; } - self.jump_to_current_match(); + self.jump_to_current_match(cx); cx.notify(); } diff --git a/shell/gpui/src/log/nav.rs b/shell/gpui/src/log/nav.rs index 4e4d6cc..3699749 100644 --- a/shell/gpui/src/log/nav.rs +++ b/shell/gpui/src/log/nav.rs @@ -45,7 +45,9 @@ impl LogView { let new = (cur + delta).clamp(0, len as i32 - 1) as usize; if Some(new) != vm.selected { self.select_change(new, cx); - self.scrolls.changes.scroll_to_item(new, ScrollStrategy::Top); + self.scrolls + .changes + .scroll_to_item(new, ScrollStrategy::Top); } } ActivePane::FileColumn => { diff --git a/shell/gpui/src/log/sidebar.rs b/shell/gpui/src/log/sidebar.rs index 98e8e04..8a78831 100644 --- a/shell/gpui/src/log/sidebar.rs +++ b/shell/gpui/src/log/sidebar.rs @@ -6,8 +6,8 @@ use gpui::{ use super::LogView; use crate::app::theme::{FONT_META, Theme}; -use crate::ui::primitives::no_scrollbar_gutter; use crate::log::commit_row::{BookmarkRightClick, CommitRow, commit_box}; +use crate::ui::primitives::no_scrollbar_gutter; pub(super) fn sidebar( view: &LogView, @@ -132,9 +132,7 @@ pub(super) fn sidebar( }), ) .track_scroll(&scroll); - no_scrollbar_gutter(list) - .h_full() - .into_any_element() + no_scrollbar_gutter(list).h_full().into_any_element() }; let load_more = if error.is_none() && !changes.is_empty() { diff --git a/shell/gpui/src/windows/command_palette/exec.rs b/shell/gpui/src/windows/command_palette/exec.rs index db8afa7..685ef55 100644 --- a/shell/gpui/src/windows/command_palette/exec.rs +++ b/shell/gpui/src/windows/command_palette/exec.rs @@ -1,4 +1,4 @@ -use std::process::Command; +use std::path::Path; use gpui::Context; @@ -30,22 +30,17 @@ impl CommandPalette { } fn execute(body: String, cwd: &str, display: String) -> CommandOutput { - let binary = jayjay_core::jj_binary(); - let mut cmd = Command::new("/bin/sh"); - cmd.arg("-c") - .arg(format!("{binary} {body}")) - .current_dir(cwd); - match cmd.output() { + match jayjay_core::run_jj_command_in_path(Path::new(cwd), &body) { Ok(out) => CommandOutput::Done { - display, - stdout: String::from_utf8_lossy(&out.stdout).to_string(), - stderr: String::from_utf8_lossy(&out.stderr).to_string(), - success: out.status.success(), + display: out.display, + stdout: out.stdout, + stderr: out.stderr, + success: out.success, }, Err(e) => CommandOutput::Done { display, stdout: String::new(), - stderr: format!("failed to spawn: {e}"), + stderr: e.to_string(), success: false, }, } diff --git a/shell/gpui/src/windows/command_palette/input.rs b/shell/gpui/src/windows/command_palette/input.rs index 7b5a700..6005d42 100644 --- a/shell/gpui/src/windows/command_palette/input.rs +++ b/shell/gpui/src/windows/command_palette/input.rs @@ -47,20 +47,8 @@ impl CommandPalette { } } - // `!` is a shorthand alias for `jj `, matching SwiftUI behavior. pub(super) fn parse_command(&self) -> Option { - let q = self.query.as_str(); - let body_after = |rest: &str| rest.trim_start().to_string(); - if q == "jj" || q == "!" { - return Some(String::new()); - } - if let Some(rest) = q.strip_prefix("jj ") { - return Some(body_after(rest)); - } - if let Some(rest) = q.strip_prefix('!') { - return Some(body_after(rest)); - } - None + jayjay_core::jj_command_body(&self.query) } pub(super) fn matches(&self) -> Vec { diff --git a/shell/gpui/src/windows/command_palette/render.rs b/shell/gpui/src/windows/command_palette/render.rs index 368b581..faa3e00 100644 --- a/shell/gpui/src/windows/command_palette/render.rs +++ b/shell/gpui/src/windows/command_palette/render.rs @@ -1,7 +1,7 @@ use gpui::{AnyElement, IntoElement, ParentElement, SharedString, Styled, div, px, rgb}; -use super::state::CommandOutput; use super::actions::{ACTIONS, PaletteAction}; +use super::state::CommandOutput; use crate::app::fonts; use crate::app::theme::Theme; use crate::ui::icons::{self, glyph}; @@ -60,11 +60,9 @@ pub(super) fn command_view( SharedString::from("Enter ⏎"), t.fg_faint, ), - Some(CommandOutput::Running { display }) => ( - display.clone(), - SharedString::from("Running…"), - t.fg_dim, - ), + Some(CommandOutput::Running { display }) => { + (display.clone(), SharedString::from("Running…"), t.fg_dim) + } Some(CommandOutput::Done { display, success, .. }) => { @@ -116,12 +114,7 @@ pub(super) fn command_view( col } -fn suggestion_row( - cmd: &str, - hint: &SharedString, - hint_color: u32, - t: &Theme, -) -> impl IntoElement { +fn suggestion_row(cmd: &str, hint: &SharedString, hint_color: u32, t: &Theme) -> impl IntoElement { div() .flex() .flex_row() diff --git a/shell/gpui/src/windows/command_palette/state.rs b/shell/gpui/src/windows/command_palette/state.rs index fd5d314..308f7ea 100644 --- a/shell/gpui/src/windows/command_palette/state.rs +++ b/shell/gpui/src/windows/command_palette/state.rs @@ -11,7 +11,9 @@ pub struct CommandPalette { #[derive(Clone)] pub(super) enum CommandOutput { Idle, - Running { display: String }, + Running { + display: String, + }, Done { display: String, stdout: String, diff --git a/shell/gpui/src/windows/evolog.rs b/shell/gpui/src/windows/evolog.rs index afe1ab1..2c584c6 100644 --- a/shell/gpui/src/windows/evolog.rs +++ b/shell/gpui/src/windows/evolog.rs @@ -7,14 +7,17 @@ use gpui::{ StatefulInteractiveElement, Styled, TitlebarOptions, Window, WindowBounds, WindowOptions, div, px, rgb, uniform_list, }; -use jayjay_core::{EvologEntry, Repo}; +use jayjay_core::{ + EvologEntry, EvologOperationKind, EvologVisibleRow, Repo, evolog_operation_kind, + evolog_visible_rows, +}; use crate::app::actions::CloseWindow; -use crate::ui::primitives::no_scrollbar_gutter; use crate::app::config::AppConfigStore; use crate::app::fonts; use crate::app::theme::{Theme, theme}; use crate::ui::icons::{self, glyph}; +use crate::ui::primitives::no_scrollbar_gutter; pub struct EvologView { repo: Arc, @@ -161,21 +164,21 @@ fn header(title: &SharedString, t: &Theme) -> AnyElement { } fn evolog_body(entries: Arc>, theme: Theme) -> AnyElement { - let count = entries.len(); + let rows = Arc::new(evolog_visible_rows(&entries, false, true)); + let count = rows.len(); let theme = Arc::new(theme); let list = uniform_list( "evolog", count, move |range: std::ops::Range, _w, _cx| { - range.map(|ix| evolog_row(&entries[ix], &theme)).collect() + range.map(|ix| evolog_row(&rows[ix], &theme)).collect() }, ); - no_scrollbar_gutter(list) - .h_full() - .into_any_element() + no_scrollbar_gutter(list).h_full().into_any_element() } -fn evolog_row(entry: &EvologEntry, t: &Theme) -> AnyElement { +fn evolog_row(row: &EvologVisibleRow, t: &Theme) -> AnyElement { + let entry = &row.entries[0]; let short_commit = entry.commit_id.chars().take(12).collect::(); let when = format_when(entry.timestamp_millis); let description = if entry.description.trim().is_empty() { @@ -191,8 +194,13 @@ fn evolog_row(entry: &EvologEntry, t: &Theme) -> AnyElement { }; let commit_for_copy = entry.commit_id.clone(); - let restore_cmd = format!("jj restore --from {commit_for_copy}"); + let restore_cmd = format!("jj restore --from {commit_for_copy} --into @"); let restore_for_copy = restore_cmd.clone(); + let operation = if row.is_snapshot_run { + format!("{} snapshots", row.entries.len()) + } else { + operation_label(&entry.operation) + }; div() .flex() @@ -213,7 +221,7 @@ fn evolog_row(entry: &EvologEntry, t: &Theme) -> AnyElement { div() .text_size(px(12.)) .text_color(rgb(t.fg)) - .child(SharedString::from(entry.operation.clone())), + .child(SharedString::from(operation)), ) .child(div().flex_1()) .child( @@ -270,6 +278,20 @@ fn evolog_row(entry: &EvologEntry, t: &Theme) -> AnyElement { .into_any_element() } +fn operation_label(raw: &str) -> String { + match evolog_operation_kind(raw) { + EvologOperationKind::Snapshot => "snapshot", + EvologOperationKind::Describe => "describe", + EvologOperationKind::Rebase => "rebase", + EvologOperationKind::Squash => "squash", + EvologOperationKind::Split => "split", + EvologOperationKind::New => "new", + EvologOperationKind::Rewrite => "rewrite", + EvologOperationKind::Other => raw, + } + .to_owned() +} + fn format_when(ts: i64) -> String { let dt: DateTime = match Local.timestamp_millis_opt(ts).single() { Some(d) => d, @@ -299,3 +321,24 @@ fn placeholder_err(text: &SharedString, t: &Theme) -> AnyElement { .child(text.clone()) .into_any_element() } + +#[cfg(test)] +mod tests { + use super::operation_label; + + #[test] + fn labels_known_operations_in_ui_layer() { + assert_eq!(operation_label("snapshot working copy 123"), "snapshot"); + assert_eq!(operation_label("describe commit abc"), "describe"); + assert_eq!(operation_label("rebase commit abc"), "rebase"); + assert_eq!(operation_label("squash commits abc"), "squash"); + assert_eq!(operation_label("split commit abc"), "split"); + assert_eq!(operation_label("new empty commit"), "new"); + assert_eq!(operation_label(""), "rewrite"); + } + + #[test] + fn preserves_unknown_operation_label() { + assert_eq!(operation_label("custom operation"), "custom operation"); + } +} diff --git a/shell/gpui/src/windows/file_history.rs b/shell/gpui/src/windows/file_history.rs index 7e1006c..bb3252f 100644 --- a/shell/gpui/src/windows/file_history.rs +++ b/shell/gpui/src/windows/file_history.rs @@ -10,12 +10,12 @@ use gpui::{ use jayjay_core::{ChangeInfo, Repo}; use crate::app::actions::CloseWindow; -use crate::ui::primitives::no_scrollbar_gutter; use crate::app::config::AppConfigStore; use crate::app::fonts; use crate::app::theme::{Theme, theme}; use crate::log::LogView; use crate::ui::icons::{self, glyph}; +use crate::ui::primitives::no_scrollbar_gutter; pub struct FileHistoryView { repo: Arc, @@ -190,9 +190,7 @@ fn history_body( .collect() }), ); - no_scrollbar_gutter(list) - .h_full() - .into_any_element() + no_scrollbar_gutter(list).h_full().into_any_element() } fn history_row( diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift index ad425f0..f4efa32 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift @@ -18,6 +18,7 @@ public final class DiffGutterTextView: NSTextView { struct Entry { let style: DiffSpanStyle let range: NSRange + let lineNumber: Int } var entries: [Entry] = [] @@ -42,10 +43,11 @@ public final class DiffGutterTextView: NSTextView { override public func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) if isInGroupColumn(point), - let lineIndex = lineIndex(at: point), - entries[safe: lineIndex]?.style.isChanged == true + let entryIndex = entryIndex(at: point), + let entry = entries[safe: entryIndex], + entry.style.isChanged { - let lineNumber = lineIndex + 1 + let lineNumber = entry.lineNumber // Review toggle takes priority over the legacy select-change-group click. if let toggleReviewCheckbox, let groupIdx = groupIndexAtLineNumber[lineNumber] @@ -61,10 +63,11 @@ public final class DiffGutterTextView: NSTextView { } if isInCheckboxColumn(point), - let lineIndex = lineIndex(at: point), - entries[safe: lineIndex]?.style.isChanged == true + let entryIndex = entryIndex(at: point), + let entry = entries[safe: entryIndex], + entry.style.isChanged { - toggleLineCheckbox?(lineIndex + 1) + toggleLineCheckbox?(entry.lineNumber) return } @@ -103,9 +106,11 @@ public final class DiffGutterTextView: NSTextView { override public func menu(for event: NSEvent) -> NSMenu? { let point = convert(event.locationInWindow, from: nil) if isInGroupColumn(point), - let lineNumber = lineNumber(for: event), - entries[safe: lineNumber - 1]?.style.isChanged == true + let entryIndex = entryIndex(at: point), + let entry = entries[safe: entryIndex], + entry.style.isChanged { + let lineNumber = entry.lineNumber let range = groupRangeProvider?(lineNumber) ?? lineNumber ... lineNumber selectionAnchorLine = range.lowerBound selectLines(range) @@ -151,13 +156,16 @@ public final class DiffGutterTextView: NSTextView { pendingMenuActions[sender.tag]?() } - private func lineIndex(for event: NSEvent) -> Int? { + private func entryIndex(for event: NSEvent) -> Int? { let point = convert(event.locationInWindow, from: nil) - return lineIndex(at: point) + return entryIndex(at: point) } private func lineNumber(for event: NSEvent) -> Int? { - lineIndex(for: event).map { $0 + 1 } + guard let entryIndex = entryIndex(for: event), + let entry = entries[safe: entryIndex] + else { return nil } + return entry.lineNumber } private func isInGroupColumn(_ point: NSPoint) -> Bool { @@ -172,7 +180,7 @@ public final class DiffGutterTextView: NSTextView { return x >= checkboxHitStart && x <= checkboxHitStart + checkboxHitWidth } - private func lineIndex(at point: NSPoint) -> Int? { + private func entryIndex(at point: NSPoint) -> Int? { guard let layoutManager, let textContainer else { return nil } @@ -202,10 +210,8 @@ public final class DiffGutterTextView: NSTextView { } private func selectLines(_ range: ClosedRange) { - let lowerIndex = range.lowerBound - 1 - let upperIndex = range.upperBound - 1 - guard let lower = entries[safe: lowerIndex], - let upper = entries[safe: upperIndex] + guard let lower = entries.first(where: { $0.lineNumber == range.lowerBound }), + let upper = entries.last(where: { $0.lineNumber == range.upperBound }) else { return } let selectedRange = NSRange( location: lower.range.location, @@ -227,23 +233,23 @@ public final class DiffGutterTextView: NSTextView { private var selectedLineRange: ClosedRange? { let selected = selectedRange() guard selected.length > 0 else { return nil } - guard let lower = entries.firstIndex(where: { NSIntersectionRange($0.range, selected).length > 0 }), - let upper = entries.lastIndex(where: { NSIntersectionRange($0.range, selected).length > 0 }) + let selectedEntries = entries.filter { NSIntersectionRange($0.range, selected).length > 0 } + guard let lower = selectedEntries.map(\.lineNumber).min(), + let upper = selectedEntries.map(\.lineNumber).max() else { return nil } - return (lower + 1) ... (upper + 1) + return lower ... upper } private var currentSelection: DiffGutterSelection? { guard let lineRange = selectedLineRange else { return nil } - let lowerIndex = lineRange.lowerBound - 1 - let upperIndex = lineRange.upperBound - 1 - guard entries.indices.contains(lowerIndex), entries.indices.contains(upperIndex) else { return nil } - let changedCount = entries[lowerIndex ... upperIndex].reduce(into: 0) { count, entry in - if entry.style == .added || entry.style == .removed { - count += 1 + let changedLines = entries.reduce(into: Set()) { lines, entry in + if lineRange.contains(entry.lineNumber), + entry.style == .added || entry.style == .removed + { + lines.insert(entry.lineNumber) } } - return DiffGutterSelection(lineRange: lineRange, changedLineCount: changedCount) + return DiffGutterSelection(lineRange: lineRange, changedLineCount: changedLines.count) } } diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextContainerView.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextContainerView.swift index 0d5db2d..4d58dfb 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextContainerView.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextContainerView.swift @@ -7,7 +7,9 @@ public final class DiffTextContainerView: NSView { let textView: NSTextView private let separatorView = NSView() private var isSyncingScroll = false + private var lastContentWidth: CGFloat = -1 private(set) var gutterWidth: CGFloat = 0 + var onContentLayoutChanged: (() -> Void)? override public var isFlipped: Bool { true @@ -54,6 +56,7 @@ public final class DiffTextContainerView: NSView { } func updateGutterWidth(_ width: CGFloat) { + guard abs(gutterWidth - width) > 0.5 else { return } gutterWidth = width needsLayout = true } @@ -70,6 +73,23 @@ public final class DiffTextContainerView: NSView { width: max(0, bounds.width - gutter - 1), height: bounds.height ) + + let contentWidth = max(0, scrollView.contentSize.width) + if abs(textView.frame.width - contentWidth) > 0.5 { + textView.frame.size.width = contentWidth + } + + guard abs(lastContentWidth - contentWidth) > 0.5 else { return } + lastContentWidth = contentWidth + textView.textContainer?.containerSize = NSSize( + width: contentWidth, + height: CGFloat.greatestFiniteMagnitude + ) + textView.layoutManager?.invalidateLayout( + forCharacterRange: NSRange(location: 0, length: (textView.string as NSString).length), + actualCharacterRange: nil + ) + onContentLayoutChanged?() } @objc private func gutterScrolled(_ notification: Notification) { diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift index 9a4c123..4713985 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift @@ -15,6 +15,37 @@ final class DiffLayoutManager: NSLayoutManager { return max(usedRect(for: textContainer).width, textContainer.textView?.bounds.width ?? 0) } + func visualLineCounts(logicalLineCount: Int) -> [Int] { + guard logicalLineCount > 0, + let textStorage, + let textContainer = textContainers.first + else { return [] } + + ensureLayout(for: textContainer) + + let text = textStorage.string as NSString + var counts: [Int] = [] + var charPos = 0 + while counts.count < logicalLineCount { + guard charPos < text.length else { + counts.append(1) + continue + } + + let lineRange = text.lineRange(for: NSRange(location: charPos, length: 0)) + let glyphRange = glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) + var fragmentCount = 0 + enumerateLineFragments(forGlyphRange: glyphRange) { _, _, _, lineGlyphRange, _ in + if NSIntersectionRange(lineGlyphRange, glyphRange).length > 0 { + fragmentCount += 1 + } + } + counts.append(max(1, fragmentCount)) + charPos = NSMaxRange(lineRange) + } + return counts + } + /// Owned buffer for clamped selection rects. Held until the next /// rectArray() call. NSTextView reads the returned rects synchronously /// before yielding, so reuse across calls is safe. @@ -43,38 +74,41 @@ final class DiffLayoutManager: NSLayoutManager { let lineRange = fullText.lineRange(for: NSRange(location: charPos, length: 0)) let glyphRange = glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) if NSIntersectionRange(glyphRange, glyphsToShow).length > 0 { - let lineRect = lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) - if lineIndex < lineBgColors.count { - let color = lineBgColors[lineIndex] - if color != .clear { - var bgRect = lineRect - bgRect.origin.x = 0 - bgRect.size.width = drawWidth - bgRect.origin.x += origin.x - bgRect.origin.y += origin.y - color.setFill() - bgRect.fill() + enumerateLineFragments(forGlyphRange: glyphRange) { lineRect, _, _, lineGlyphRange, _ in + guard NSIntersectionRange(lineGlyphRange, glyphsToShow).length > 0 else { return } + + if lineIndex < self.lineBgColors.count { + let color = self.lineBgColors[lineIndex] + if color != .clear { + var bgRect = lineRect + bgRect.origin.x = 0 + bgRect.size.width = drawWidth + bgRect.origin.x += origin.x + bgRect.origin.y += origin.y + color.setFill() + bgRect.fill() + } } - } - // Selection takes over the stripe column while active. - let isSelected = selectedCharRanges.contains { selRange in - NSIntersectionRange(lineRange, selRange).length > 0 - } - if !isSelected, - lineStripeWidth > 0, - lineIndex < lineStripeColors.count - { - let color = lineStripeColors[lineIndex] - if color != .clear { - // Overlap ±1pt so adjacent stripes have no sub-pixel seams. - var stripeRect = lineRect - stripeRect.origin.x = lineStripeX + origin.x - stripeRect.origin.y += origin.y - 1 - stripeRect.size.width = lineStripeWidth - stripeRect.size.height += 2 - color.setFill() - stripeRect.fill() + // Selection takes over the stripe column while active. + let isSelected = selectedCharRanges.contains { selRange in + NSIntersectionRange(lineRange, selRange).length > 0 + } + if !isSelected, + self.lineStripeWidth > 0, + lineIndex < self.lineStripeColors.count + { + let color = self.lineStripeColors[lineIndex] + if color != .clear { + // Overlap ±1pt so adjacent stripes have no sub-pixel seams. + var stripeRect = lineRect + stripeRect.origin.x = self.lineStripeX + origin.x + stripeRect.origin.y += origin.y - 1 + stripeRect.size.width = self.lineStripeWidth + stripeRect.size.height += 2 + color.setFill() + stripeRect.fill() + } } } } diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+Gutter.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+Gutter.swift index c978c94..59cc1e5 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+Gutter.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+Gutter.swift @@ -69,12 +69,6 @@ extension NativeDiffView { } } - func separatorGutterText(maxLineDigits: Int, showsLineCheckboxes: Bool) -> String { - let blankNumber = String(repeating: " ", count: maxLineDigits) - let checkboxColumn = showsLineCheckboxes ? " " : "" - return " \(checkboxColumn)\(blankNumber) \(blankNumber) \n" - } - func groupText() -> String { " " } diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+WrappedGutter.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+WrappedGutter.swift new file mode 100644 index 0000000..c3b5a9a --- /dev/null +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+WrappedGutter.swift @@ -0,0 +1,202 @@ +import AppKit +import JayJayCore + +struct NativeDiffGutterRenderContext { + let visualLineCounts: [Int] + let font: NSFont + let theme: DiffColors + let gutterAttrs: [NSAttributedString.Key: Any] + let gutterParagraphStyle: NSMutableParagraphStyle + let maxLineDigits: Int + let showsReviewCheckboxes: Bool + let showsCheckboxColumn: Bool + let firstLineOfGroup: [Int: UInt32] + let groupIndexAtLineNumber: [Int: UInt32] + let reviewActions: (any DiffGutterReviewActions)? + let groupStripeWidth: CGFloat + let gutterHorizontalInset: CGFloat + let gutterTrailingPadding: CGFloat + let currentSelectedLineRange: ClosedRange? +} + +extension NativeDiffView { + func renderWrappedGutter( + gutterTextView: DiffGutterTextView, + gutterLayoutManager: DiffLayoutManager, + context: NativeDiffGutterRenderContext + ) -> CGFloat { + let gutter = NSMutableAttributedString() + var gutterEntries: [DiffGutterTextView.Entry] = [] + var gutterWidth: CGFloat = 0 + var gutterLineBgColors: [NSColor] = [] + var groupStripeColors: [NSColor] = [] + + func appendGutterLine( + _ line: NSAttributedString, + style: DiffSpanStyle, + lineNumber: Int, + bgColor: NSColor, + stripeColor: NSColor + ) { + let start = gutter.length + gutter.append(line) + gutterEntries.append(.init( + style: style, + range: NSRange(location: start, length: gutter.length - start), + lineNumber: lineNumber + )) + gutterLineBgColors.append(bgColor) + groupStripeColors.append(stripeColor) + } + + func blankGutterLine() -> NSAttributedString { + let blankNumber = String(repeating: " ", count: context.maxLineDigits) + let leftColumn = context.showsReviewCheckboxes ? " " : " " + let checkboxColumn = context.showsCheckboxColumn ? " " : "" + return NSAttributedString( + string: "\(leftColumn)\(checkboxColumn)\(blankNumber) \(blankNumber)\n", + attributes: context.gutterAttrs + ) + } + + for (index, line) in diff.lines.enumerated() { + let lineNumber = index + 1 + let visualRows = index < context.visualLineCounts.count + ? max(1, context.visualLineCounts[index]) + : 1 + let bgColor = line.style == .separator ? context.theme.separatorBg : context.theme.lineBg(line.style) + let stripeColor = stripeColor(for: line, lineNumber: lineNumber, context: context) + + if line.style == .separator { + for _ in 0.. 1 { + for _ in 1.. NSColor { + if context.showsReviewCheckboxes, + let groupIdx = context.groupIndexAtLineNumber[lineNumber] + { + return context.reviewActions?.isHunkReviewed(groupIndex: groupIdx) == true + ? NSColor.controlAccentColor + : NSColor.selectedTextBackgroundColor + } + return groupStripeColor( + for: line, + groupRange: expandedHunkRange(containing: lineNumber ... lineNumber), + theme: context.theme + ) + } + + private func firstVisualGutterLine( + for line: DiffLine, + lineNumber: Int, + context: NativeDiffGutterRenderContext + ) -> NSMutableAttributedString { + let isFirstOfReviewedGroup = context.showsReviewCheckboxes + && context.firstLineOfGroup[lineNumber] + .map { context.reviewActions?.isHunkReviewed(groupIndex: $0) == true } == true + let leftColumnString: String = if context.showsReviewCheckboxes { + isFirstOfReviewedGroup ? " ✓ " : " " + } else { + " " + } + let gutterLine = NSMutableAttributedString( + string: leftColumnString, + attributes: [ + .font: context.font, + .foregroundColor: isFirstOfReviewedGroup + ? NSColor.controlAccentColor + : context.theme.gutterText, + .paragraphStyle: context.gutterParagraphStyle + ] + ) + if context.showsCheckboxColumn { + gutterLine.append(NSAttributedString( + string: checkboxText(for: lineNumber, line: line), + attributes: [ + .font: context.font, + .foregroundColor: checkboxColor(for: lineNumber, theme: context.theme), + .paragraphStyle: context.gutterParagraphStyle + ] + )) + } + gutterLine.append(NSAttributedString( + string: pad(line.oldLineNo.map(String.init) ?? "", toWidth: context.maxLineDigits), + attributes: context.gutterAttrs + )) + gutterLine.append(NSAttributedString(string: " ", attributes: context.gutterAttrs)) + gutterLine.append(NSAttributedString( + string: pad(line.newLineNo.map(String.init) ?? "", toWidth: context.maxLineDigits), + attributes: context.gutterAttrs + )) + gutterLine.append(NSAttributedString(string: "\n", attributes: context.gutterAttrs)) + return gutterLine + } +} diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift index 1832a9d..a24723f 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift @@ -48,17 +48,17 @@ public struct NativeDiffView: NSViewRepresentable { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = true + scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.drawsBackground = false - // No-wrap: wrapping would split one diff line into multiple visual rows and break gutter↔content alignment. let textContainer = NSTextContainer(containerSize: NSSize( - width: CGFloat.greatestFiniteMagnitude, + width: 0, height: CGFloat.greatestFiniteMagnitude )) - textContainer.widthTracksTextView = false + textContainer.widthTracksTextView = true textContainer.lineFragmentPadding = 4 + textContainer.lineBreakMode = .byWordWrapping let layoutManager = DiffLayoutManager() layoutManager.addTextContainer(textContainer) @@ -71,7 +71,7 @@ public struct NativeDiffView: NSViewRepresentable { textView.isSelectable = true textView.autoresizingMask = [.width] textView.isVerticallyResizable = true - textView.isHorizontallyResizable = true + textView.isHorizontallyResizable = false textView.textContainerInset = NSSize(width: 4, height: 8) textView.drawsBackground = false textView.usesFindBar = true @@ -136,9 +136,6 @@ public struct NativeDiffView: NSViewRepresentable { } let result = NSMutableAttributedString() - let gutter = NSMutableAttributedString() - var gutterEntries: [DiffGutterTextView.Entry] = [] - var gutterWidth: CGFloat = 0 // Review mode reserves a third char so the ✓ glyph fits. let leftColumnText = showsReviewCheckboxes ? " ✓ " : " " let groupWidth = (leftColumnText as NSString).size(withAttributes: [.font: font]).width @@ -153,87 +150,17 @@ public struct NativeDiffView: NSViewRepresentable { let lineNumber = max(line.oldLineNo ?? 0, line.newLineNo ?? 0) digits = max(digits, String(lineNumber).count) } - var lineBgColors: [NSColor] = [] - var groupStripeColors: [NSColor] = [] + var contentLineBgColors: [NSColor] = [] - for (index, line) in diff.lines.enumerated() { + for line in diff.lines { if line.style == .separator { result.append(NSAttributedString(string: "⋯ \(line.spans.first?.text ?? "")\n", attributes: [ .font: font, .foregroundColor: theme.gutterText ])) - let gutterStart = gutter.length - gutter.append(NSAttributedString( - string: separatorGutterText( - maxLineDigits: maxLineDigits, - showsLineCheckboxes: showsCheckboxColumn - ), - attributes: gutterAttrs - )) - gutterEntries.append(.init( - style: line.style, - range: NSRange(location: gutterStart, length: gutter.length - gutterStart) - )) - lineBgColors.append(theme.separatorBg) - groupStripeColors.append(.clear) + contentLineBgColors.append(theme.separatorBg) continue } - // ✓ glyph beside the stripe on the first line of a reviewed group. - let isFirstOfReviewedGroup = showsReviewCheckboxes - && firstLineOfGroup[index + 1].map { reviewActions?.isHunkReviewed(groupIndex: $0) == true } == true - let leftColumnString: String = if showsReviewCheckboxes { - isFirstOfReviewedGroup ? " ✓ " : " " - } else { - " " - } - let gutterLine = NSMutableAttributedString( - string: leftColumnString, - attributes: [ - .font: font, - .foregroundColor: isFirstOfReviewedGroup - ? NSColor.controlAccentColor - : theme.gutterText, - .paragraphStyle: gutterParagraphStyle - ] - ) - if showsLineCheckboxes { - gutterLine.append(NSAttributedString( - string: checkboxText(for: index + 1, line: line), - attributes: [ - .font: font, - .foregroundColor: checkboxColor(for: index + 1, theme: theme), - .paragraphStyle: gutterParagraphStyle - ] - )) - } - gutterLine.append(NSAttributedString( - string: pad(line.oldLineNo.map(String.init) ?? "", toWidth: maxLineDigits), - attributes: gutterAttrs - )) - gutterLine.append(NSAttributedString(string: " ", attributes: gutterAttrs)) - gutterLine.append(NSAttributedString( - string: pad(line.newLineNo.map(String.init) ?? "", toWidth: maxLineDigits), - attributes: gutterAttrs - )) - gutterLine.append(NSAttributedString(string: "\n", attributes: gutterAttrs)) - let gutterStart = gutter.length - gutter.append(gutterLine) - gutterEntries.append(.init( - style: line.style, - range: NSRange(location: gutterStart, length: gutter.length - gutterStart) - )) - - let gutterLineWidth = gutterLine.size().width - gutterWidth = max( - gutterWidth, - ceil( - gutterHorizontalInset + - gutterLineWidth + - gutterTrailingPadding + - gutterHorizontalInset - ) - ) - // Content spans with word-level highlighting for span in line.spans { let foreground = theme.tokenColor(span.token, fallback: theme.lineText(line.style)) @@ -255,23 +182,7 @@ public struct NativeDiffView: NSViewRepresentable { result.append(NSAttributedString(string: " no newline at EOF", attributes: dim)) } result.append(NSAttributedString(string: "\n", attributes: [.font: font])) - lineBgColors.append(theme.lineBg(line.style)) - let displayLine = index + 1 - if showsReviewCheckboxes, - let groupIdx = groupIndexAtLineNumber[displayLine] - { - // Unreviewed matches the gutter's right-click selection color. - let reviewed = reviewActions?.isHunkReviewed(groupIndex: groupIdx) == true - groupStripeColors.append(reviewed - ? NSColor.controlAccentColor - : NSColor.selectedTextBackgroundColor) - } else { - groupStripeColors.append(groupStripeColor( - for: line, - groupRange: expandedHunkRange(containing: displayLine ... displayLine), - theme: theme - )) - } + contentLineBgColors.append(theme.lineBg(line.style)) } if diff.lines.isEmpty { @@ -279,23 +190,12 @@ public struct NativeDiffView: NSViewRepresentable { string: "No differences", attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor] )) - let gutterStart = gutter.length - gutter.append(NSAttributedString(string: "\n", attributes: gutterAttrs)) - gutterEntries.append(.init( - style: .context, - range: NSRange(location: gutterStart, length: gutter.length - gutterStart) - )) + contentLineBgColors.append(.clear) } - gutterLayoutManager.lineBgColors = lineBgColors - gutterLayoutManager.lineStripeColors = groupStripeColors - gutterLayoutManager.lineStripeX = 0 - gutterLayoutManager.lineStripeWidth = groupStripeWidth - layoutManager.lineBgColors = lineBgColors + layoutManager.lineBgColors = contentLineBgColors layoutManager.lineStripeColors = [] layoutManager.lineStripeWidth = 0 - gutterTextView.textStorage?.setAttributedString(gutter) - gutterTextView.entries = gutterEntries gutterTextView.menuProvider = menuProvider(selection:) gutterTextView.groupRangeProvider = { lineNumber in expandedHunkRange(containing: lineNumber ... lineNumber) @@ -323,8 +223,37 @@ public struct NativeDiffView: NSViewRepresentable { gutterTextView.onSelectionChanged = { selection in gutterActions?.didSelectLines(selection.lineRange) } - gutterTextView.externalSelection = gutterActions?.currentSelectedLineRange textView.textStorage?.setAttributedString(result) - containerView.updateGutterWidth(max(52, gutterWidth)) + + let renderGutter = { [weak containerView] in + guard let containerView else { return } + + let logicalLineCount = max(diff.lines.count, 1) + let gutterWidth = renderWrappedGutter( + gutterTextView: gutterTextView, + gutterLayoutManager: gutterLayoutManager, + context: NativeDiffGutterRenderContext( + visualLineCounts: layoutManager.visualLineCounts(logicalLineCount: logicalLineCount), + font: font, + theme: theme, + gutterAttrs: gutterAttrs, + gutterParagraphStyle: gutterParagraphStyle, + maxLineDigits: maxLineDigits, + showsReviewCheckboxes: showsReviewCheckboxes, + showsCheckboxColumn: showsCheckboxColumn, + firstLineOfGroup: firstLineOfGroup, + groupIndexAtLineNumber: groupIndexAtLineNumber, + reviewActions: reviewActions, + groupStripeWidth: groupStripeWidth, + gutterHorizontalInset: gutterHorizontalInset, + gutterTrailingPadding: gutterTrailingPadding, + currentSelectedLineRange: gutterActions?.currentSelectedLineRange + ) + ) + containerView.updateGutterWidth(max(52, gutterWidth)) + } + + containerView.onContentLayoutChanged = renderGutter + renderGutter() } } diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/SideBySideDiffRendering.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/SideBySideDiffRendering.swift index 3e5cc97..84e25d6 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/SideBySideDiffRendering.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/SideBySideDiffRendering.swift @@ -128,7 +128,11 @@ extension SideBySideRepresentable { if style == .separator { let start = str.length str.append(NSAttributedString(string: "\n", attributes: attrs)) - entries.append(.init(style: style, range: NSRange(location: start, length: str.length - start))) + entries.append(.init( + style: style, + range: NSRange(location: start, length: str.length - start), + lineNumber: entries.count + 1 + )) return } @@ -137,7 +141,11 @@ extension SideBySideRepresentable { line.append(NSAttributedString(string: "\n", attributes: attrs)) let start = str.length str.append(line) - entries.append(.init(style: style, range: NSRange(location: start, length: str.length - start))) + entries.append(.init( + style: style, + range: NSRange(location: start, length: str.length - start), + lineNumber: entries.count + 1 + )) let numberWidth = (padded as NSString).size(withAttributes: attrs).width width = max(width, ceil(inset + numberWidth + trailingPadding + inset)) diff --git a/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffLayoutManagerTests.swift b/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffLayoutManagerTests.swift index eed7100..23d8b1c 100644 --- a/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffLayoutManagerTests.swift +++ b/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffLayoutManagerTests.swift @@ -3,16 +3,13 @@ import AppKit import XCTest final class DiffLayoutManagerTests: XCTestCase { - /// No-wrap invariant: every newline-terminated line must map to exactly one - /// line fragment. If a long line wraps, the gutter (one row per diff line) - /// falls out of alignment with the content text view — the regression we - /// fixed by switching `NativeDiffView`'s content container to a no-wrap - /// layout with horizontal scrolling. - func test_longLines_doNotWrap_intoExtraFragments() { - let (manager, storage) = makeNoWrapLayout() + /// Wrapped content must report how many visual rows each logical diff line + /// occupies so the gutter can insert blank continuation rows. + func test_longLinesWrap_intoExtraFragments() { + let (manager, storage) = makeWrappingLayout(width: 120) let shortLine = "fn main() {\n" // Much wider than any sensible viewport. - let longLine = String(repeating: "x", count: 2000) + "\n" + let longLine = String(repeating: "wrapped ", count: 80) + "\n" let finalLine = "}\n" storage.append(NSAttributedString( string: shortLine + longLine + finalLine, @@ -21,10 +18,12 @@ final class DiffLayoutManagerTests: XCTestCase { manager.ensureLayout(for: manager.textContainers[0]) - // One fragment per newline-terminated line — the long line must not - // split into multiple fragments from wrapping. let fragmentCount = countLineFragments(in: manager) - XCTAssertEqual(fragmentCount, 3) + XCTAssertGreaterThan(fragmentCount, 3) + let visualLineCounts = manager.visualLineCounts(logicalLineCount: 3) + XCTAssertEqual(visualLineCounts[0], 1) + XCTAssertGreaterThan(visualLineCounts[1], 1) + XCTAssertEqual(visualLineCounts[2], 1) } private func countLineFragments(in manager: NSLayoutManager) -> Int { @@ -147,4 +146,21 @@ final class DiffLayoutManagerTests: XCTestCase { storage.addLayoutManager(manager) return (manager, storage) } + + private func makeWrappingLayout(width: CGFloat) -> (DiffLayoutManager, NSTextStorage) { + let container = NSTextContainer(containerSize: NSSize( + width: width, + height: CGFloat.greatestFiniteMagnitude + )) + container.widthTracksTextView = false + container.lineFragmentPadding = 4 + container.lineBreakMode = .byWordWrapping + + let manager = DiffLayoutManager() + manager.addTextContainer(container) + + let storage = NSTextStorage() + storage.addLayoutManager(manager) + return (manager, storage) + } } diff --git a/shell/mac/Sources/JayJay/App/Config/AppSettings.swift b/shell/mac/Sources/JayJay/App/Config/AppSettings.swift index f999852..8cf37b3 100644 --- a/shell/mac/Sources/JayJay/App/Config/AppSettings.swift +++ b/shell/mac/Sources/JayJay/App/Config/AppSettings.swift @@ -1,4 +1,5 @@ import Foundation +import JayJayCore @Observable final class AppSettings { @@ -16,6 +17,9 @@ final class AppSettings { static let recentRepos = "jayjay.recentRepos" static let lastOpenedRepo = "jayjay.lastOpenedRepo" static let hasCompletedOnboarding = "jayjay.hasCompletedOnboarding" + static let savedRevsets = "jayjay.savedRevsets" + static let evologHideSnapshots = "jayjay.evologHideSnapshots" + static let evologCollapseSnapshotRuns = "jayjay.evologCollapseSnapshotRuns" static let skipAbandonConfirmation = "jayjay.skipAbandonConfirmation" static let confirmDragRebase = "jayjay.confirmDragRebase" static let externalEditor = "jayjay.externalEditor" @@ -102,6 +106,23 @@ final class AppSettings { ) } } + var savedRevsets: [SavedRevset] { + didSet { saveSavedRevsets() } + } + + var evologHideSnapshots: Bool { + didSet { defaults.set(evologHideSnapshots, forKey: StorageKeys.evologHideSnapshots) } + } + + var evologCollapseSnapshotRuns: Bool { + didSet { + defaults.set( + evologCollapseSnapshotRuns, + forKey: StorageKeys.evologCollapseSnapshotRuns + ) + } + } + // MARK: - Tools var externalEditor: ExternalEditor { @@ -161,6 +182,11 @@ final class AppSettings { recentRepos = (defaults.stringArray(forKey: StorageKeys.recentRepos) ?? []).filter { !$0.isEmpty } lastOpenedRepo = defaults.string(forKey: StorageKeys.lastOpenedRepo) hasCompletedOnboarding = defaults.bool(forKey: StorageKeys.hasCompletedOnboarding) + savedRevsets = Self.loadSavedRevsets(defaults: defaults) + evologHideSnapshots = defaults.bool(forKey: StorageKeys.evologHideSnapshots) + evologCollapseSnapshotRuns = defaults.object( + forKey: StorageKeys.evologCollapseSnapshotRuns + ) as? Bool ?? true externalEditor = ExternalEditor(rawValue: defaults.string(forKey: StorageKeys.externalEditor) ?? "") ?? .vscode customEditorCommand = defaults.string(forKey: StorageKeys.customEditorCommand) ?? "" terminal = Terminal(rawValue: defaults.string(forKey: StorageKeys.terminal) ?? "") ?? .terminal @@ -186,4 +212,26 @@ final class AppSettings { lastOpenedRepo = recentRepos.first } } + + func saveRevset(name: String, expression: String) { + savedRevsets = upsertSavedRevset(existing: savedRevsets, name: name, expression: expression) + } + + func removeSavedRevset(id: String) { + savedRevsets = JayJayCore.removeSavedRevset(existing: savedRevsets, id: id) + } + + private static func loadSavedRevsets(defaults: UserDefaults) -> [SavedRevset] { + if let json = defaults.string(forKey: StorageKeys.savedRevsets) { + return decodeSavedRevsetsJson(json: json) + } + guard let data = defaults.data(forKey: StorageKeys.savedRevsets), + let json = String(data: data, encoding: .utf8) + else { return [] } + return decodeSavedRevsetsJson(json: json) + } + + private func saveSavedRevsets() { + defaults.set(encodeSavedRevsetsJson(revsets: savedRevsets), forKey: StorageKeys.savedRevsets) + } } diff --git a/shell/mac/Sources/JayJay/App/Config/SavedRevset.swift b/shell/mac/Sources/JayJay/App/Config/SavedRevset.swift new file mode 100644 index 0000000..3b81d27 --- /dev/null +++ b/shell/mac/Sources/JayJay/App/Config/SavedRevset.swift @@ -0,0 +1,7 @@ +import JayJayCore + +extension SavedRevset { + static var builtIns: [SavedRevset] { + builtInRevsets() + } +} diff --git a/shell/mac/Sources/JayJay/Detail/DetailView.swift b/shell/mac/Sources/JayJay/Detail/DetailView.swift index f4ba446..5f144ff 100644 --- a/shell/mac/Sources/JayJay/Detail/DetailView.swift +++ b/shell/mac/Sources/JayJay/Detail/DetailView.swift @@ -16,6 +16,7 @@ struct DetailView: View { var evologEntries: [EvologEntry]? var evologRev: String? var onDismissEvolog: (() -> Void)? + var onRestoreEvologCommit: ((String) -> Void)? var body: some View { if let entries = evologEntries, let rev = evologRev { @@ -24,7 +25,8 @@ struct DetailView: View { changeId: rev, repo: repo, diffStore: diffStore, - onDismiss: { onDismissEvolog?() } + onDismiss: { onDismissEvolog?() }, + onRestoreCommit: { onRestoreEvologCommit?($0) } ) .id(rev) } else if let detail { diff --git a/shell/mac/Sources/JayJay/Detail/EvologDisplay.swift b/shell/mac/Sources/JayJay/Detail/EvologDisplay.swift index 093e425..d6c8b9a 100644 --- a/shell/mac/Sources/JayJay/Detail/EvologDisplay.swift +++ b/shell/mac/Sources/JayJay/Detail/EvologDisplay.swift @@ -1,7 +1,7 @@ import JayJayCore import SwiftUI -/// Pure display helpers for EvologView — formatters, label/icon mappings. +/// Presentation helpers for EvologView. enum EvologDisplay { static func timestamp(_ millis: Int64) -> String { let date = Date(timeIntervalSince1970: Double(millis) / 1000) @@ -10,26 +10,32 @@ enum EvologDisplay { return formatter.localizedString(for: date, relativeTo: Date()) } - /// Shorten verbose jj operation strings for display. Falls back to the raw value. static func operationLabel(_ raw: String) -> String { - if raw.hasPrefix("snapshot working copy") { return "snapshot" } - if raw.hasPrefix("describe commit ") { return "describe" } - if raw.hasPrefix("rebase commit ") { return "rebase" } - if raw.hasPrefix("squash commits ") { return "squash" } - if raw.hasPrefix("split commit ") { return "split" } - if raw.hasPrefix("new empty commit") { return "new" } - return raw.isEmpty ? "rewrite" : raw + switch evologOperationKind(raw: raw) { + case .snapshot: String(localized: "snapshot") + case .describe: String(localized: "describe") + case .rebase: String(localized: "rebase") + case .squash: String(localized: "squash") + case .split: String(localized: "split") + case .new: String(localized: "new") + case .rewrite: String(localized: "rewrite") + case .other: raw + } + } + + static func isSnapshot(_ raw: String) -> Bool { + evologOperationKind(raw: raw) == .snapshot } static func operationIcon(_ raw: String) -> String { - switch operationLabel(raw) { - case "snapshot": "camera" - case "describe": "text.cursor" - case "rebase": "arrow.uturn.up" - case "squash": "arrow.down.left.circle" - case "split": "rectangle.split.2x1" - case "new": "plus.circle" - default: "circle.dotted" + switch evologOperationKind(raw: raw) { + case .snapshot: "camera" + case .describe: "text.cursor" + case .rebase: "arrow.uturn.up" + case .squash: "arrow.down.left.circle" + case .split: "rectangle.split.2x1" + case .new: "plus.circle" + case .rewrite, .other: "circle.dotted" } } diff --git a/shell/mac/Sources/JayJay/Detail/EvologView.swift b/shell/mac/Sources/JayJay/Detail/EvologView.swift index e04737e..60c6ac5 100644 --- a/shell/mac/Sources/JayJay/Detail/EvologView.swift +++ b/shell/mac/Sources/JayJay/Detail/EvologView.swift @@ -3,19 +3,23 @@ import SwiftUI struct EvologView: View { @State private var viewModel: EvologViewModel + @Environment(AppSettings.self) private var settings let onDismiss: () -> Void + let onRestoreCommit: (String) -> Void init( entries: [EvologEntry], changeId: String, repo: JayJayRepo?, diffStore: DiffStore, - onDismiss: @escaping () -> Void + onDismiss: @escaping () -> Void, + onRestoreCommit: @escaping (String) -> Void ) { _viewModel = State(wrappedValue: EvologViewModel( entries: entries, changeId: changeId, repo: repo, diffStore: diffStore )) self.onDismiss = onDismiss + self.onRestoreCommit = onRestoreCommit } var body: some View { @@ -30,11 +34,20 @@ struct EvologView: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - HSplitView { - entryList - .frame(minWidth: 240, idealWidth: 280, maxWidth: 360) - diffPane - .frame(maxWidth: .infinity, maxHeight: .infinity) + if viewModel.visibleRows.isEmpty { + ContentUnavailableView( + "Snapshots Hidden", + systemImage: "camera", + description: Text("Turn off Hide snapshots to see these versions.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + HSplitView { + entryList + .frame(minWidth: 260, idealWidth: 300, maxWidth: 380) + diffPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } } @@ -48,7 +61,26 @@ struct EvologView: View { .jayjayFont(13, weight: .semibold, design: .monospaced) .lineLimit(1) Spacer() - Text("\(viewModel.entries.count) version\(viewModel.entries.count == 1 ? "" : "s")") + Toggle("Hide snapshots", isOn: Binding( + get: { viewModel.hideSnapshots }, + set: { + settings.evologHideSnapshots = $0 + viewModel.setHideSnapshots($0) + } + )) + .toggleStyle(.checkbox) + .jayjayFont(11) + Toggle("Collapse runs", isOn: Binding( + get: { viewModel.collapseSnapshotRuns }, + set: { + settings.evologCollapseSnapshotRuns = $0 + viewModel.setCollapseSnapshotRuns($0) + } + )) + .toggleStyle(.checkbox) + .jayjayFont(11) + .disabled(viewModel.hideSnapshots) + Text(versionSummary) .jayjayFont(11) .foregroundStyle(.secondary) Button("Done", action: onDismiss) @@ -57,18 +89,22 @@ struct EvologView: View { } .padding(.horizontal, 14) .padding(.vertical, 8) + .onAppear { + viewModel.setHideSnapshots(settings.evologHideSnapshots) + viewModel.setCollapseSnapshotRuns(settings.evologCollapseSnapshotRuns) + } } private var entryList: some View { List( - Array(viewModel.entries.enumerated()), - id: \.offset, + viewModel.visibleRows, + id: \.id, selection: Binding( get: { viewModel.selectedIndex }, set: { viewModel.selectedIndex = $0 } ) - ) { idx, entry in - entryRow(idx: idx, entry: entry).tag(idx) + ) { row in + entryRow(row: row).tag(Int(row.primaryIndex)) } .listStyle(.plain) .onChange(of: viewModel.selectedIndex) { _, newIndex in @@ -76,13 +112,25 @@ struct EvologView: View { } } - private func entryRow(idx _: Int, entry: EvologEntry) -> some View { - VStack(alignment: .leading, spacing: 3) { + private var versionSummary: String { + let visible = viewModel.visibleRows.count + let suffix = visible == 1 ? "" : "s" + if viewModel.hiddenSnapshotCount > 0 { + return "\(visible) version\(suffix), \(viewModel.hiddenSnapshotCount) hidden" + } + return "\(visible) version\(suffix)" + } + + private func entryRow(row: EvologVisibleRow) -> some View { + let entry = row.primary + return VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { - Image(systemName: EvologDisplay.operationIcon(entry.operation)) + Image(systemName: row.isSnapshotRun ? "camera.on.rectangle" : EvologDisplay + .operationIcon(entry.operation)) .jayjayFont(10) .foregroundStyle(.secondary) - Text(EvologDisplay.operationLabel(entry.operation)) + Text(row.isSnapshotRun ? "\(row.entries.count) snapshots" : EvologDisplay + .operationLabel(entry.operation)) .jayjayFont(12, weight: .medium) .lineLimit(1) Spacer() @@ -94,13 +142,14 @@ struct EvologView: View { Text(String(entry.commitId.prefix(12))) .jayjayFont(10, design: .monospaced) .foregroundStyle(Color.accentColor.opacity(0.8)) - if !entry.description.isEmpty { - Text(entry.description) + if let subtitle = rowSubtitle(row) { + Text(subtitle) .jayjayFont(11) .foregroundStyle(.secondary) .lineLimit(1) } } + restoreButton(commitId: entry.commitId) } .padding(.vertical, 2) .contentShape(Rectangle()) @@ -115,7 +164,32 @@ struct EvologView: View { } label: { Label("Copy ‘jj restore’ command", systemImage: "terminal") } + Button { + onRestoreCommit(entry.commitId) + } label: { + Label("Restore to @", systemImage: "arrow.counterclockwise") + } + .disabled(entry.commitId == viewModel.headCommitId) + } + } + + private func rowSubtitle(_ row: EvologVisibleRow) -> String? { + if row.isSnapshotRun, let last = row.entries.last { + return "\(String(row.primary.commitId.prefix(8)))...\(String(last.commitId.prefix(8)))" + } + return row.primary.description.isEmpty ? nil : row.primary.description + } + + private func restoreButton(commitId: String) -> some View { + Button { + onRestoreCommit(commitId) + } label: { + Label("Restore to @", systemImage: "arrow.counterclockwise") + .jayjayFont(10, weight: .medium) } + .buttonStyle(.borderless) + .controlSize(.small) + .disabled(commitId == viewModel.headCommitId) } @ViewBuilder diff --git a/shell/mac/Sources/JayJay/Detail/EvologViewModel.swift b/shell/mac/Sources/JayJay/Detail/EvologViewModel.swift index 58589c6..5821964 100644 --- a/shell/mac/Sources/JayJay/Detail/EvologViewModel.swift +++ b/shell/mac/Sources/JayJay/Detail/EvologViewModel.swift @@ -2,6 +2,12 @@ import AppKit import JayJayCore import SwiftUI +extension EvologVisibleRow { + var primary: EvologEntry { + entries[0] + } +} + @Observable final class EvologViewModel { let entries: [EvologEntry] @@ -14,19 +20,70 @@ final class EvologViewModel { var interdiffLoading = false var selectedPath: String? var selectedHunk: DiffHunk? + var hideSnapshots = false + var collapseSnapshotRuns = true /// Most recent commit_id (entries are newest-first); we diff older versions against this. - var headCommitId: String? { entries.first?.commitId } + var headCommitId: String? { + entries.first?.commitId + } var selectedFromCommitId: String? { selectedIndex.flatMap { entries.indices.contains($0) ? entries[$0].commitId : nil } } - init(entries: [EvologEntry], changeId: String, repo: JayJayRepo?, diffStore: DiffStore) { + var visibleRows: [EvologVisibleRow] { + evologVisibleRows( + entries: entries, + hideSnapshots: hideSnapshots, + collapseSnapshotRuns: collapseSnapshotRuns + ) + } + + var hiddenSnapshotCount: Int { + hideSnapshots ? entries.filter { EvologDisplay.isSnapshot($0.operation) }.count : 0 + } + + init( + entries: [EvologEntry], + changeId: String, + repo: JayJayRepo?, + diffStore: DiffStore, + hideSnapshots: Bool = false, + collapseSnapshotRuns: Bool = true + ) { self.entries = entries self.changeId = changeId self.repo = repo self.diffStore = diffStore + self.hideSnapshots = hideSnapshots + self.collapseSnapshotRuns = collapseSnapshotRuns + } + + func setHideSnapshots(_ value: Bool) { + hideSnapshots = value + normalizeSelection() + } + + func setCollapseSnapshotRuns(_ value: Bool) { + collapseSnapshotRuns = value + normalizeSelection() + } + + func normalizeSelection() { + guard let selectedIndex else { return } + guard let selectedRowIndex = UInt32(exactly: selectedIndex), + let row = visibleRows.first(where: { $0.indices.contains(selectedRowIndex) }) + else { + self.selectedIndex = nil + loadInterdiff(for: nil) + return + } + let primaryIndex = Int(row.primaryIndex) + if primaryIndex != selectedIndex { + self.selectedIndex = primaryIndex + loadInterdiff(for: primaryIndex) + } } func loadInterdiff(for index: Int?) { @@ -45,13 +102,13 @@ final class EvologViewModel { Task.detached { [weak self] in let detail = try? repo.interdiffSummary(fromRev: from, toRev: to) await MainActor.run { [weak self] in - guard let self, self.selectedIndex == index else { return } - self.interdiffLoading = false - self.interdiffDetail = detail + guard let self, selectedIndex == index else { return } + interdiffLoading = false + interdiffDetail = detail if let firstPath = detail?.diff.first?.path { - self.selectedPath = firstPath + selectedPath = firstPath // file-list `onChange` doesn't fire until that view mounts; trigger the load here. - self.loadFile(path: firstPath) + loadFile(path: firstPath) } } } @@ -65,8 +122,8 @@ final class EvologViewModel { Task.detached { [weak self] in let hunk = try? repo.interdiffFile(fromRev: from, toRev: to, path: path) await MainActor.run { [weak self] in - guard let self, self.selectedPath == path else { return } - self.selectedHunk = hunk + guard let self, selectedPath == path else { return } + selectedHunk = hunk } } } diff --git a/shell/mac/Sources/JayJay/Diff/DiffSection.swift b/shell/mac/Sources/JayJay/Diff/DiffSection.swift index 2c7d724..b53ea27 100644 --- a/shell/mac/Sources/JayJay/Diff/DiffSection.swift +++ b/shell/mac/Sources/JayJay/Diff/DiffSection.swift @@ -354,22 +354,19 @@ struct DiffSection: View, DiffGutterEditActions, DiffGutterReviewActions { let fullLineIndices = fullDiff.lines.enumerated().compactMap { index, line in selectedKeys.contains(diffLineKey(line)) ? index + 1 : nil } - let ranges = collapsedRanges(fullLineIndices) - guard !ranges.isEmpty else { return } + guard let selection = buildDiffEditFileSelection( + hunk: hunk, + diff: fullDiff, + oldContent: oldContent, + newContent: newContent, + selectedLines: fullLineIndices.map(UInt32.init).sorted(), + inverse: false + ) else { return } actions.applyDiffSelection( rev: rev, destination: .removeFromSource, - selections: [ - DiffEditFileSelection( - path: hunk.path, - oldPath: hunk.oldPath, - oldContent: oldContent, - newContent: newContent, - hunkType: hunk.hunkType, - lineRanges: ranges - ) - ], + selections: [selection], message: "", ignoreWhitespace: settings.ignoreWhitespace ) @@ -386,27 +383,6 @@ struct DiffSection: View, DiffGutterEditActions, DiffGutterReviewActions { return "\(style)|\(line.oldLineNo.map(String.init) ?? "-")|\(line.newLineNo.map(String.init) ?? "-")" } - private func collapsedRanges(_ indices: [Int]) -> [DiffEditRange] { - guard let first = indices.first else { return [] } - - var ranges: [DiffEditRange] = [] - var start = first - var previous = first - - for index in indices.dropFirst() { - if index == previous + 1 { - previous = index - continue - } - ranges.append(DiffEditRange(startLine: UInt32(start), endLine: UInt32(previous))) - start = index - previous = index - } - - ranges.append(DiffEditRange(startLine: UInt32(start), endLine: UInt32(previous))) - return ranges - } - var currentSelectedLineRange: ClosedRange? { selectedLineRange } diff --git a/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift b/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift index c69a8a6..681e9cf 100644 --- a/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift +++ b/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift @@ -110,9 +110,11 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions { } private var supportsDiffEdit: Bool { - hunk.hunkType != .renamed - && DiffPlaceholder.isEditableText(oldContent) - && DiffPlaceholder.isEditableText(newContent) + diffEditSupportsFile( + hunkType: hunk.hunkType, + oldContent: oldContent ?? hunk.oldContent, + newContent: newContent ?? hunk.newContent + ) } private var fileCheckboxText: String { @@ -127,30 +129,23 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions { isLoading = true loadError = nil - // Reuse DiffStore for file content loading (cached if already loaded by DiffSection) - let cached = await diffStore.loadDiff( - hunk: hunk, rev: rev, repo: repo, ignoreWhitespace: settings.ignoreWhitespace - ) - let old = cached?.oldContent - let new = cached?.newContent - let ignoreWhitespace = settings.ignoreWhitespace - let path = hunk.path - - // DiffEdit needs the full (uncollapsed) diff — line selection indices - // must match the full diff the Rust side computes when applying. - let diff = await Task.detached { - repo.computeNativeDiffFull( - path: path, oldContent: old ?? "", newContent: new ?? "", - ignoreWhitespace: ignoreWhitespace - ) - }.value + guard let loaded = await loadDiffEditFile( + hunk: hunk, + rev: rev, + repo: repo, + diffStore: diffStore, + ignoreWhitespace: settings.ignoreWhitespace + ) else { + isLoading = false + return + } - oldContent = old - newContent = new - fileDiff = diff + oldContent = loaded.oldContent + newContent = loaded.newContent + fileDiff = loaded.diff // Collapse context for display, with mapping back to full diff line numbers - let collapsed = repo.collapseDiffWithMapping(diff: diff) + let collapsed = repo.collapseDiffWithMapping(diff: loaded.diff) displayDiff = collapsed.diff displayToFullMap = Dictionary( uniqueKeysWithValues: collapsed.displayToFull.map { @@ -159,7 +154,7 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions { ) isLoading = false - onLoaded(DiffEditLoadedFile(hunk: hunk, oldContent: old, newContent: new, diff: diff)) + onLoaded(loaded) } private func diffHeight(for diff: FileDiff) -> CGFloat { @@ -186,10 +181,11 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions { } private func selectionBadgeText(fileDiff: FileDiff) -> String { - let changedLineCount = fileDiff.lines.filter(\.isChanged).count + let changedLines = Set(diffEditChangedLines(diff: fileDiff).map(Int.init)) + let changedLineCount = changedLines.count let selectedLineCount = fileDiff.lines.enumerated().reduce(into: 0) { count, entry in let lineNumber = entry.offset + 1 - if entry.element.isChanged, selectedChangedLines.contains(lineNumber) { + if changedLines.contains(lineNumber), selectedChangedLines.contains(lineNumber) { count += 1 } } @@ -203,9 +199,7 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions { } private func lineCheckboxState(fileDiff: FileDiff, lineNumber: Int) -> DiffGutterCheckboxState? { - let lineIndex = lineNumber - 1 - guard fileDiff.lines.indices.contains(lineIndex) else { return nil } - guard fileDiff.lines[lineIndex].isChanged else { return nil } + guard diffEditChangedLines(diff: fileDiff).contains(UInt32(lineNumber)) else { return nil } return selectedChangedLines.contains(lineNumber) ? .selected : .unselected } @@ -241,9 +235,3 @@ struct DiffEditFileSection: View, DiffGutterSelectionActions { onToggleLine(fullLine) } } - -private extension DiffLine { - var isChanged: Bool { - style == .added || style == .removed - } -} diff --git a/shell/mac/Sources/JayJay/DiffEdit/DiffEditModels.swift b/shell/mac/Sources/JayJay/DiffEdit/DiffEditModels.swift index 35cfe25..8b66107 100644 --- a/shell/mac/Sources/JayJay/DiffEdit/DiffEditModels.swift +++ b/shell/mac/Sources/JayJay/DiffEdit/DiffEditModels.swift @@ -1,5 +1,5 @@ -import JayJayCore import Foundation +import JayJayCore struct DiffEditLoadedFile { let hunk: DiffHunk @@ -8,64 +8,95 @@ struct DiffEditLoadedFile { let diff: FileDiff var changedLineNumbers: [Int] { - diff.lines.enumerated().compactMap { index, line in - line.isChanged ? index + 1 : nil - } + diffEditChangedLines(diff: diff).map(Int.init) } var changedLineSet: Set { Set(changedLineNumbers) } + var supportsDiffEdit: Bool { + diffEditSupportsFile( + hunkType: hunk.hunkType, + oldContent: effectiveOldContent, + newContent: effectiveNewContent + ) + } + func changedLineCount(selectedLines: Set) -> Int { changedLineNumbers.filter(selectedLines.contains).count } func makeSelection(selectedLines: Set) -> DiffEditFileSelection? { - let lineNumbers = changedLineNumbers.filter(selectedLines.contains) - let ranges = collapseRanges(lineNumbers) - guard !ranges.isEmpty else { return nil } - - return DiffEditFileSelection( - path: hunk.path, - oldPath: hunk.oldPath, - oldContent: oldContent, - newContent: newContent, - hunkType: hunk.hunkType, - lineRanges: ranges.map { - DiffEditRange(startLine: UInt32($0.lowerBound), endLine: UInt32($0.upperBound)) - } + buildDiffEditFileSelection( + hunk: hunk, + diff: diff, + oldContent: effectiveOldContent, + newContent: effectiveNewContent, + selectedLines: selectedLines.map(UInt32.init).sorted(), + inverse: false ) } func makeInverseSelection(selectedLines: Set) -> DiffEditFileSelection? { - makeSelection(selectedLines: changedLineSet.subtracting(selectedLines)) + buildDiffEditFileSelection( + hunk: hunk, + diff: diff, + oldContent: effectiveOldContent, + newContent: effectiveNewContent, + selectedLines: selectedLines.map(UInt32.init).sorted(), + inverse: true + ) + } + + private var effectiveOldContent: String? { + oldContent ?? hunk.oldContent + } + + private var effectiveNewContent: String? { + newContent ?? hunk.newContent } } -private func collapseRanges(_ lineNumbers: [Int]) -> [ClosedRange] { - guard let first = lineNumbers.first else { return [] } +func loadDiffEditFile( + hunk: DiffHunk, + rev: String, + repo: JayJayRepo?, + diffStore: DiffStore, + ignoreWhitespace: Bool +) async -> DiffEditLoadedFile? { + guard let repo else { return nil } - var ranges: [ClosedRange] = [] - var start = first - var previous = first + let cached = await diffStore.loadDiff( + hunk: hunk, rev: rev, repo: repo, ignoreWhitespace: ignoreWhitespace + ) + let old = cached?.oldContent ?? hunk.oldContent + let new = cached?.newContent ?? hunk.newContent + let path = hunk.path - for lineNumber in lineNumbers.dropFirst() { - if lineNumber == previous + 1 { - previous = lineNumber - continue - } - ranges.append(start ... previous) - start = lineNumber - previous = lineNumber - } + let diff = await Task.detached { + repo.computeNativeDiffFull( + path: path, + oldContent: old ?? "", + newContent: new ?? "", + ignoreWhitespace: ignoreWhitespace + ) + }.value - ranges.append(start ... previous) - return ranges + return DiffEditLoadedFile(hunk: hunk, oldContent: old, newContent: new, diff: diff) } -private extension DiffLine { - var isChanged: Bool { - style == .added || style == .removed +func buildDiffEditSelections( + loadedFiles: [String: DiffEditLoadedFile], + selectedChangedLinesByPath: [String: Set], + destination: DiffEditDestination +) -> [DiffEditFileSelection] { + loadedFiles.compactMap { path, loaded in + guard loaded.supportsDiffEdit else { return nil } + let selectedLines = selectedChangedLinesByPath[path] ?? [] + if destination == .removeFromSource { + return loaded.makeInverseSelection(selectedLines: selectedLines) + } + return loaded.makeSelection(selectedLines: selectedLines) } } diff --git a/shell/mac/Sources/JayJay/DiffEdit/DiffEditView.swift b/shell/mac/Sources/JayJay/DiffEdit/DiffEditView.swift index 96f2989..505b131 100644 --- a/shell/mac/Sources/JayJay/DiffEdit/DiffEditView.swift +++ b/shell/mac/Sources/JayJay/DiffEdit/DiffEditView.swift @@ -1,5 +1,4 @@ import JayJayCore -import JayJayDiffUI import SwiftUI struct DiffEditView: View { @@ -13,6 +12,10 @@ struct DiffEditView: View { @State private var selectedChangedLinesByPath: [String: Set] = [:] @State private var newChangeMessage = "" @State private var showEmptySelectionAlert = false + @State private var selectAllAsFilesLoad = false + @State private var isPreparingSelectAll = false + @State private var selectAllLoadTask: Task? + @State private var selectAllGeneration = 0 @Environment(AppSettings.self) private var settings var body: some View { @@ -66,6 +69,12 @@ struct DiffEditView: View { .jayjayFont(12, design: .monospaced) .foregroundStyle(.secondary) Spacer() + Button("Select All") { selectAllChanges() } + .controlSize(.small) + .disabled(isPreparingSelectAll) + Button("Clear") { clearSelection() } + .controlSize(.small) + .disabled(!hasVisibleSelection && !isPreparingSelectAll) Text(selectionSummary) .jayjayFont(11) .foregroundStyle(.secondary) @@ -77,13 +86,27 @@ struct DiffEditView: View { } private var unsupportedNotice: some View { - HStack(spacing: 10) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - Text("Renames and non-text files can be previewed here but are not editable yet.") - .jayjayFont(12) - .foregroundStyle(.secondary) - Spacer() + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(unsupportedSummary) + .jayjayFont(12, weight: .medium) + .foregroundStyle(.secondary) + Spacer() + } + ForEach(unsupportedDetails.prefix(4), id: \.self) { detail in + Text(detail) + .jayjayFont(11, design: .monospaced) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + if unsupportedDetails.count > 4 { + Text("\(unsupportedDetails.count - 4) more unsupported file\(unsupportedDetails.count == 5 ? "" : "s")") + .jayjayFont(11) + .foregroundStyle(.tertiary) + } } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -94,8 +117,13 @@ struct DiffEditView: View { VStack(spacing: 10) { Divider() HStack(spacing: 12) { - Text(selectionSummary) - .jayjayFont(12, weight: .medium) + VStack(alignment: .leading, spacing: 3) { + Text(selectionSummary) + .jayjayFont(12, weight: .medium) + Text(topologyHelp) + .jayjayFont(11) + .foregroundStyle(.secondary) + } Spacer() if !detail.info.isWorkingCopy { TextField("New change description", text: $newChangeMessage) @@ -105,15 +133,19 @@ struct DiffEditView: View { if !detail.info.isWorkingCopy { Button("Create New Child Change") { apply(.newChild) } .buttonStyle(.borderedProminent) + .disabled(isPreparingSelectAll) Button("Create Parallel Change") { apply(.newParallel) } .buttonStyle(.bordered) + .disabled(isPreparingSelectAll) Button("Move to Working Copy") { apply(.moveToWorkingCopy) } .buttonStyle(.bordered) + .disabled(isPreparingSelectAll) } Button("Done") { apply(.removeFromSource) } .buttonStyle(.bordered) + .disabled(isPreparingSelectAll) } .padding(.horizontal, 18) .padding(.bottom, 12) @@ -122,14 +154,35 @@ struct DiffEditView: View { } private var hasUnsupportedFiles: Bool { - detail.diff.contains { hunk in - hunk.hunkType == .renamed - || !DiffPlaceholder.isEditableText(hunk.oldContent) - || !DiffPlaceholder.isEditableText(hunk.newContent) + !unsupportedDetails.isEmpty + } + + private var unsupportedDetails: [String] { + detail.diff.compactMap { hunk in + let loaded = loadedFiles[hunk.path] + return diffEditUnsupportedReason( + hunkType: hunk.hunkType, + oldContent: loaded?.oldContent ?? hunk.oldContent, + newContent: loaded?.newContent ?? hunk.newContent + ).map { "\(hunk.path) — \($0)" } + } + } + + private var unsupportedSummary: String { + "\(unsupportedDetails.count) file\(unsupportedDetails.count == 1 ? "" : "s") can be previewed but not edited" + } + + private var topologyHelp: String { + if detail.info.isWorkingCopy { + return "Done keeps selected changes in @ and abandons unchecked changes." } + return "Child/Parallel/Working Copy extract selected changes; Done keeps selected changes here and removes unchecked changes." } private var selectionSummary: String { + if isPreparingSelectAll { + return "Loading all editable files..." + } let selectedFiles = builtSelections().count let selectedLines = selectedChangedLinesByPath.reduce(into: 0) { count, entry in guard let loaded = loadedFiles[entry.key] else { return } @@ -148,16 +201,22 @@ struct DiffEditView: View { } private func builtSelections(for destination: DiffEditDestination = .newChild) -> [DiffEditFileSelection] { - loadedFiles.compactMap { path, loaded in - let selectedLines = selectedChangedLinesByPath[path] ?? [] - if destination == .removeFromSource { - return loaded.makeInverseSelection(selectedLines: selectedLines) - } - return loaded.makeSelection(selectedLines: selectedLines) - } + buildDiffEditSelections( + loadedFiles: loadedFiles, + selectedChangedLinesByPath: selectedChangedLinesByPath, + destination: destination + ) } private func apply(_ destination: DiffEditDestination) { + Task { await applyPrepared(destination) } + } + + @MainActor + private func applyPrepared(_ destination: DiffEditDestination) async { + if selectAllAsFilesLoad, let selectAllLoadTask { + await selectAllLoadTask.value + } guard hasVisibleSelection else { showEmptySelectionAlert = true return @@ -186,13 +245,15 @@ struct DiffEditView: View { if let existing = selectedChangedLinesByPath[path] { selectedChangedLinesByPath[path] = existing.intersection(changedLines) + } else if selectAllAsFilesLoad, loaded.supportsDiffEdit { + selectedChangedLinesByPath[path] = changedLines } else { selectedChangedLinesByPath[path] = [] } } private func toggleFileSelection(path: String) { - guard let loaded = loadedFiles[path] else { return } + guard let loaded = loadedFiles[path], loaded.supportsDiffEdit else { return } let changedLines = loaded.changedLineSet let selected = selectedChangedLinesByPath[path] ?? [] if changedLines.isSubset(of: selected) { @@ -203,12 +264,13 @@ struct DiffEditView: View { } private func selectFile(path: String) { - guard let loaded = loadedFiles[path] else { return } + guard let loaded = loadedFiles[path], loaded.supportsDiffEdit else { return } selectedChangedLinesByPath[path] = loaded.changedLineSet } private func toggleLineSelection(path: String, lineNumber: Int) { - guard let loaded = loadedFiles[path], loaded.changedLineSet.contains(lineNumber) else { return } + guard let loaded = loadedFiles[path], loaded.supportsDiffEdit, + loaded.changedLineSet.contains(lineNumber) else { return } var selected = selectedChangedLinesByPath[path] ?? [] if selected.contains(lineNumber) { selected.remove(lineNumber) @@ -219,11 +281,70 @@ struct DiffEditView: View { } private func selectHunk(path: String, range: ClosedRange) { - guard let loaded = loadedFiles[path] else { return } + guard let loaded = loadedFiles[path], loaded.supportsDiffEdit else { return } let changedLines = Set(loaded.changedLineNumbers.filter(range.contains)) guard !changedLines.isEmpty else { return } var selected = selectedChangedLinesByPath[path] ?? [] selected.formUnion(changedLines) selectedChangedLinesByPath[path] = selected } + + private func selectAllChanges() { + selectAllGeneration += 1 + selectAllAsFilesLoad = true + isPreparingSelectAll = true + for (path, loaded) in loadedFiles where loaded.supportsDiffEdit { + selectedChangedLinesByPath[path] = loaded.changedLineSet + } + let generation = selectAllGeneration + let task = Task { @MainActor in + await loadAllFilesForSelectAll(generation: generation) + } + selectAllLoadTask = task + } + + private func clearSelection() { + selectAllGeneration += 1 + selectAllAsFilesLoad = false + isPreparingSelectAll = false + selectAllLoadTask?.cancel() + selectAllLoadTask = nil + selectedChangedLinesByPath = loadedFiles.keys.reduce(into: [:]) { result, path in + result[path] = [] + } + } + + @MainActor + private func loadAllFilesForSelectAll(generation: Int) async { + defer { + if generation == selectAllGeneration { + isPreparingSelectAll = false + selectAllLoadTask = nil + } + } + + for hunk in detail.diff { + if Task.isCancelled { return } + let loaded: DiffEditLoadedFile? + if let existing = loadedFiles[hunk.path] { + loaded = existing + } else { + loaded = await loadDiffEditFile( + hunk: hunk, + rev: detail.info.changeId, + repo: repo, + diffStore: diffStore, + ignoreWhitespace: settings.ignoreWhitespace + ) + } + + guard generation == selectAllGeneration else { return } + guard let loaded else { continue } + loadedFiles[hunk.path] = loaded + syncSelection(path: hunk.path, loaded: loaded) + if loaded.supportsDiffEdit { + selectedChangedLinesByPath[hunk.path] = loaded.changedLineSet + } + } + } } diff --git a/shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift b/shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift index 36c81b3..633864c 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGRebaseGesturePolicy.swift @@ -26,6 +26,7 @@ enum DAGRebaseGesturePolicy { rebaseDrag: DAGRebaseDragState?, previewTargetCommitId: String?, hoveredCommitId: String?, + hoveredPlacement: DAGRebasePlacement?, entries: [GraphEntry] ) -> DAGRebaseRequest? { guard let rebaseDrag, @@ -36,6 +37,9 @@ enum DAGRebaseGesturePolicy { return nil } + let placement = hoveredPlacement ?? .onto + guard !(targetEntry.change.isImmutable && placement == .before) else { return nil } + return DAGRebaseRequest( sourceRev: rebaseDrag.sourceRev, sourceChangeId: rebaseDrag.sourceChangeId, @@ -44,7 +48,8 @@ enum DAGRebaseGesturePolicy { destRev: revision(for: targetEntry.change), destChangeId: targetEntry.change.changeId, destCommitId: targetEntry.change.commitId, - destLabel: displayLabel(for: targetEntry.change) + destLabel: displayLabel(for: targetEntry.change), + placement: placement ) } @@ -99,6 +104,23 @@ enum DAGRebaseGesturePolicy { hoveredCommitId == sourceCommitId ? nil : hoveredCommitId } + static func placement(location: CGPoint, rowFrame: CGRect?) -> DAGRebasePlacement? { + guard let rowFrame, rowFrame.contains(location), rowFrame.height > 0 else { return nil } + let relativeY = (location.y - rowFrame.minY) / rowFrame.height + if relativeY < 0.3 { return .before } + if relativeY > 0.7 { return .after } + return .onto + } + + static func validPlacement( + location: CGPoint, + rowFrame: CGRect?, + targetIsImmutable: Bool + ) -> DAGRebasePlacement? { + let placement = placement(location: location, rowFrame: rowFrame) + return targetIsImmutable && placement == .before ? nil : placement + } + static func revision(for change: ChangeInfo) -> String { change.isDivergent ? change.commitId : change.changeId } diff --git a/shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift b/shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift index 0766b80..aa99dbd 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGRebaseModels.swift @@ -1,5 +1,42 @@ import CoreGraphics import Foundation +import JayJayCore + +typealias DAGRebasePlacement = RebasePlacement + +extension RebasePlacement { + var label: String { + switch self { + case .onto: "onto" + case .after: "after" + case .before: "before" + } + } + + var targetRole: String { + switch self { + case .onto: "New parent" + case .after: "Insert after" + case .before: "Insert before" + } + } + + var confirmationLabel: String { + switch self { + case .onto: "Rebase" + case .after: "Insert After" + case .before: "Insert Before" + } + } + + var releaseHint: String { + switch self { + case .onto: "Release to rebase onto" + case .after: "Release to insert after" + case .before: "Release to insert before" + } + } +} struct DAGRebaseRequest: Identifiable { let id = UUID() @@ -11,6 +48,7 @@ struct DAGRebaseRequest: Identifiable { let destChangeId: String let destCommitId: String let destLabel: String + let placement: DAGRebasePlacement } enum DAGRebasePhase { @@ -27,4 +65,5 @@ struct DAGRebaseDragState { var phase: DAGRebasePhase var location: CGPoint var hoveredCommitId: String? + var hoveredPlacement: DAGRebasePlacement? } diff --git a/shell/mac/Sources/JayJay/Repo/DAGRow+RebaseChrome.swift b/shell/mac/Sources/JayJay/Repo/DAGRow+RebaseChrome.swift new file mode 100644 index 0000000..cc6b13b --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/DAGRow+RebaseChrome.swift @@ -0,0 +1,94 @@ +import SwiftUI + +extension DAGRow { + @ViewBuilder + var leadingAccent: some View { + if let accent = viewModel.leadingAccentColor { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(accent) + .frame(width: 3) + } + } + + @ViewBuilder + var rebaseOutline: some View { + switch viewModel.outlineState { + case .hoverTarget?: + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.accentColor, lineWidth: 2) + .padding(.vertical, 2) + case .armed?: + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke( + Color.accentColor.opacity(0.7), + style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]) + ) + .padding(.vertical, 2) + case nil: + EmptyView() + } + } + + @ViewBuilder + var rebaseBeforeGuide: some View { + if viewModel.hoverPlacement == .before { + rebaseInsertGuide + .padding(.horizontal, 8) + } + } + + @ViewBuilder + var rebaseAfterGuide: some View { + if viewModel.hoverPlacement == .after { + rebaseInsertGuide + .padding(.horizontal, 8) + } + } + + @ViewBuilder + var dragTargetBubbleOverlay: some View { + if let dragTargetText = viewModel.dragTargetText { + dragTargetBubble(dragTargetText) + .padding(.trailing, 10) + } + } + + private func dragTargetBubble(_ text: String) -> some View { + HStack(spacing: 6) { + Text(text) + .jayjayFont(10, weight: .medium) + .lineLimit(1) + if viewModel.showsReturnHint { + hintChip("return") + } + hintChip("esc") + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + viewModel.isRebaseHoverTarget ? Color.accentColor.opacity(0.14) : Color.clear, + in: Capsule() + ) + .background(.regularMaterial, in: Capsule()) + .overlay( + Capsule() + .stroke(Color.accentColor.opacity(viewModel.isRebaseHoverTarget ? 0.5 : 0.2), lineWidth: 1) + ) + } + + private var rebaseInsertGuide: some View { + Capsule() + .fill(Color.accentColor) + .frame(height: 3) + .shadow(color: .accentColor.opacity(0.35), radius: 4) + } + + private func hintChip(_ text: String) -> some View { + Text(text.uppercased()) + .jayjayFont(8, weight: .semibold, design: .monospaced) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.06), in: Capsule()) + } +} diff --git a/shell/mac/Sources/JayJay/Repo/DAGRow.swift b/shell/mac/Sources/JayJay/Repo/DAGRow.swift index d409b44..3ad062f 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGRow.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGRow.swift @@ -23,6 +23,20 @@ struct DAGRow: View { } private func rowBody(wiggleAngle: Double) -> some View { + rowContent + .padding(.leading, dagRowLeadingPadding) + .background(viewModel.rowBackground) + .rotationEffect(.degrees(wiggleAngle)) + .scaleEffect(viewModel.scale) + .opacity(viewModel.opacity) + .overlay(alignment: .leading) { leadingAccent } + .overlay { rebaseOutline } + .overlay(alignment: .top) { rebaseBeforeGuide } + .overlay(alignment: .bottom) { rebaseAfterGuide } + .overlay(alignment: .trailing) { dragTargetBubbleOverlay } + } + + private var rowContent: some View { HStack(alignment: .top, spacing: 0) { graphColumn .frame(width: viewModel.graphWidth) @@ -64,41 +78,6 @@ struct DAGRow: View { .padding(.trailing, 10) Spacer(minLength: 0) } - .padding(.leading, dagRowLeadingPadding) - .background(viewModel.rowBackground) - .rotationEffect(.degrees(wiggleAngle)) - .scaleEffect(viewModel.scale) - .opacity(viewModel.opacity) - .overlay(alignment: .leading) { - if let accent = viewModel.leadingAccentColor { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(accent) - .frame(width: 3) - } - } - .overlay { - switch viewModel.outlineState { - case .hoverTarget?: - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.accentColor, lineWidth: 2) - .padding(.vertical, 2) - case .armed?: - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke( - Color.accentColor.opacity(0.7), - style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]) - ) - .padding(.vertical, 2) - case nil: - EmptyView() - } - } - .overlay(alignment: .trailing) { - if let dragTargetText = viewModel.dragTargetText { - dragTargetBubble(dragTargetText) - .padding(.trailing, 10) - } - } } private var graphColumn: some View { @@ -255,36 +234,4 @@ struct DAGRow: View { private func shortId(_ id: String) -> String { String(id.prefix(12)) } - - private func dragTargetBubble(_ text: String) -> some View { - HStack(spacing: 6) { - Text(text) - .jayjayFont(10, weight: .medium) - .lineLimit(1) - if viewModel.showsReturnHint { - hintChip("return") - } - hintChip("esc") - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - viewModel.isRebaseHoverTarget ? Color.accentColor.opacity(0.14) : Color.clear, - in: Capsule() - ) - .background(.regularMaterial, in: Capsule()) - .overlay( - Capsule() - .stroke(Color.accentColor.opacity(viewModel.isRebaseHoverTarget ? 0.5 : 0.2), lineWidth: 1) - ) - } - - private func hintChip(_ text: String) -> some View { - Text(text.uppercased()) - .jayjayFont(8, weight: .semibold, design: .monospaced) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(Color.primary.opacity(0.06), in: Capsule()) - } } diff --git a/shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift b/shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift index 99c9288..f57fe34 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGRowViewModel.swift @@ -25,7 +25,7 @@ enum DAGRowRebaseState: Equatable { case sourceArmed(armedAt: Date?) case sourceDragging case candidate - case hoverTarget(previewText: String?) + case hoverTarget(previewText: String?, placement: DAGRebasePlacement) } struct DAGRowViewModel { @@ -75,7 +75,10 @@ struct DAGRowViewModel { } } else if rebaseDrag != nil { if rebaseDrag?.hoveredCommitId == entry.change.commitId { - rebaseState = .hoverTarget(previewText: rebasePreviewText) + rebaseState = .hoverTarget( + previewText: rebasePreviewText, + placement: rebaseDrag?.hoveredPlacement ?? .onto + ) } else { rebaseState = .candidate } @@ -178,7 +181,7 @@ struct DAGRowViewModel { } var showsReturnHint: Bool { - if case .hoverTarget(let previewText) = rebaseState { + if case let .hoverTarget(previewText, _) = rebaseState { return previewText != nil } return false @@ -186,15 +189,22 @@ struct DAGRowViewModel { var dragTargetText: String? { switch rebaseState { - case .hoverTarget(let previewText): - previewText ?? "Release to rebase here" + case let .hoverTarget(previewText, placement): + previewText ?? placement.releaseHint case .sourceArmed: - "Drag to choose a new parent" + "Drag to choose onto, before, or after" default: nil } } + var hoverPlacement: DAGRebasePlacement? { + if case let .hoverTarget(_, placement) = rebaseState { + return placement + } + return nil + } + var scale: CGFloat { isRebaseArmed ? 1.01 : 1 } @@ -204,7 +214,7 @@ struct DAGRowViewModel { } func wiggleAngle(at date: Date) -> Double { - guard case .sourceArmed(let armedAt) = rebaseState, + guard case let .sourceArmed(armedAt) = rebaseState, let armedAt, date.timeIntervalSince(armedAt) >= 0.12 else { return 0 } diff --git a/shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift b/shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift index af119cb..1d2d675 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGView+RebaseDrag.swift @@ -38,7 +38,8 @@ extension DAGView { guard rebasePreviewTargetId == change.commitId, let rebaseDrag else { return nil } - return "Rebase \(rebaseDrag.sourceLabel) onto \(DAGRebaseGesturePolicy.displayLabel(for: change))?" + let placement = rebaseDrag.hoveredPlacement ?? .onto + return "\(placement.confirmationLabel) \(rebaseDrag.sourceLabel) \(placement.label) \(DAGRebaseGesturePolicy.displayLabel(for: change))?" } private func handleRebaseGestureChanged( @@ -111,7 +112,8 @@ extension DAGView { armedAt: nil, phase: .pressing, location: seedLocation, - hoveredCommitId: nil + hoveredCommitId: nil, + hoveredPlacement: nil ) scheduleRebaseArm(for: entry) } @@ -141,13 +143,26 @@ extension DAGView { private func updateRebaseDrag(location: CGPoint) { guard var rebaseDrag else { return } - let hoveredCommitId = rebaseRowFrames.first(where: { $0.value.contains(location) })?.key - let normalizedTarget = DAGRebaseGesturePolicy.normalizedTargetCommitId( + let hoveredRow = rebaseRowFrames.first(where: { $0.value.contains(location) }) + let hoveredCommitId = hoveredRow?.key + var normalizedTarget = DAGRebaseGesturePolicy.normalizedTargetCommitId( sourceCommitId: rebaseDrag.sourceCommitId, hoveredCommitId: hoveredCommitId ) + let targetEntry = entries.first(where: { $0.change.commitId == normalizedTarget }) + let placement = normalizedTarget == nil + ? nil + : DAGRebaseGesturePolicy.validPlacement( + location: location, + rowFrame: hoveredRow?.value, + targetIsImmutable: targetEntry?.change.isImmutable ?? false + ) + if placement == nil { + normalizedTarget = nil + } rebaseDrag.location = location rebaseDrag.hoveredCommitId = normalizedTarget + rebaseDrag.hoveredPlacement = placement self.rebaseDrag = rebaseDrag updateRebasePreviewTarget(normalizedTarget) } @@ -178,6 +193,7 @@ extension DAGView { rebaseDrag: rebaseDrag, previewTargetCommitId: rebasePreviewTargetId, hoveredCommitId: rebaseDrag?.hoveredCommitId, + hoveredPlacement: rebaseDrag?.hoveredPlacement, entries: entries ) else { cancelRebaseDrag() @@ -189,7 +205,7 @@ extension DAGView { if let onRequestRebase { onRequestRebase(request) } else { - actions?.rebase(rev: request.sourceRev, dest: request.destRev) + actions?.rebase(rev: request.sourceRev, dest: request.destRev, placement: request.placement) } } diff --git a/shell/mac/Sources/JayJay/Repo/DAGView.swift b/shell/mac/Sources/JayJay/Repo/DAGView.swift index 5e3b003..3d1a31d 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGView.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGView.swift @@ -134,6 +134,16 @@ struct DAGView: View { Button { actions?.rebase(rev: selRev, dest: rev) } label: { Label("Rebase selected onto this", systemImage: "arrow.uturn.up") } + Button { actions?.rebase(rev: selRev, dest: rev, placement: .after) } label: { + Label("Insert selected after this", systemImage: "arrow.down.to.line") + } + if !entry.change.isImmutable { + Button { + actions?.rebase(rev: selRev, dest: rev, placement: .before) + } label: { + Label("Insert selected before this", systemImage: "arrow.up.to.line") + } + } if !entry.change.isImmutable { Button { actions?.squash(rev: selRev, into: rev) } label: { Label( @@ -170,7 +180,7 @@ struct DAGView: View { Label("Absorb into ancestors", systemImage: "arrow.down.to.line") } } - Button { actions?.backout(rev: rev) } label: { + Button { actions?.revertChange(rev: rev) } label: { Label("Revert change", systemImage: "arrow.uturn.backward") } } label: { diff --git a/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift b/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift index 66ddaff..b4a7262 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoContentView+CommandPalette.swift @@ -1,3 +1,4 @@ +import JayJayCore import SwiftUI extension RepoContentView { @@ -29,28 +30,39 @@ extension RepoContentView { category: "View" ) { settings.hideGitLfsDiffs.toggle() }) - for (label, revset) in [ - ("Show All", "all()"), - ("Show Mine", "mine()"), - ("Show Bookmarks", "bookmarks()"), - ("Show Conflicts", "conflict()"), - ("Show Mutable", "mutable()"), - ("Show Trunk", "trunk().."), - ("Reset Filter", RepoViewModel.buildDefaultRevset()) - ] { + for item in SavedRevset.builtIns { items.append(CommandPaletteItem( - title: label, + title: "Show \(item.name)", icon: "line.3.horizontal.decrease.circle", category: "Filter" ) { - revsetDraft = revset + revsetDraft = item.expression applyRevset() }) } - - items.append(CommandPaletteItem(title: "Git Pull (fetch + rebase)", icon: "arrow.down.circle", category: "Git") { - viewModel.gitFetch() + for item in settings.savedRevsets { + items.append(CommandPaletteItem( + title: "Show \(item.name)", + icon: "bookmark.circle", + category: "Saved Filter" + ) { + revsetDraft = item.expression + applyRevset() + }) + } + items.append(CommandPaletteItem( + title: "Reset Filter", + icon: "line.3.horizontal.decrease.circle", + category: "Filter" + ) { + revsetDraft = RepoViewModel.buildDefaultRevset() + applyRevset() }) + + items + .append(CommandPaletteItem(title: "Git Pull (fetch + rebase)", icon: "arrow.down.circle", category: "Git") { + viewModel.gitFetch() + }) items.append(CommandPaletteItem(title: "Git Push", icon: "arrow.up.circle", category: "Git") { viewModel.gitPush(bookmark: "") }) @@ -93,7 +105,7 @@ extension RepoContentView { title: "Revert Change (\(short))", icon: "arrow.uturn.backward", category: "Change" - ) { viewModel.backout(rev: selection) }) + ) { viewModel.revertChange(rev: selection) }) items.append(CommandPaletteItem( title: "Create Bookmark on \(short)", icon: "bookmark", diff --git a/shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift b/shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift index cc373c0..c1fe5cc 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoContentView+Presentation.swift @@ -21,7 +21,7 @@ extension RepoContentView { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.ultraThinMaterial) - case .toast(let toast): + case let .toast(toast): RepoToastView( toast: toast, dismiss: dismissToast, @@ -81,7 +81,7 @@ extension RepoContentView { func alertMessage(for alert: RepoAlertState) -> String { switch alert { - case .error(let message), .configWarning(let message): + case let .error(message), let .configWarning(message): message } } @@ -89,12 +89,14 @@ extension RepoContentView { @ViewBuilder func modalView(for modal: RepoModalState) -> some View { switch modal { - case .createBookmark(let rev): + case let .createBookmark(rev): bookmarkCreateSheet(rev: rev) - case .confirmAbandon(let rev): + case let .confirmAbandon(rev): abandonSheet(rev: rev) - case .confirmRebase(let request): + case let .confirmRebase(request): rebaseConfirmationSheet(request: request) + case .saveRevset: + revsetSaveSheet case .submoduleAttention: submoduleAttentionSheet case .undoLog: @@ -249,48 +251,6 @@ extension RepoContentView { .frame(width: 340) } - private func rebaseConfirmationSheet(request: DAGRebaseRequest) -> some View { - SheetContainer( - title: "Rebase Change?", - subtitle: "\(String(request.sourceCommitId.prefix(12))) -> \(String(request.destCommitId.prefix(12)))", - cancelLabel: "Cancel", - confirmLabel: "Rebase", - onCancel: { modal = nil }, - onConfirm: { - modal = nil - runDAGRebase(request) - }, - content: { - VStack(alignment: .leading, spacing: 12) { - rebaseSummaryRow( - title: "Change", - value: request.sourceLabel, - detail: request.sourceChangeId - ) - Label("Will become a child of", systemImage: "arrow.down") - .jayjayFont(11) - .foregroundStyle(.secondary) - rebaseSummaryRow( - title: "New parent", - value: request.destLabel, - detail: request.destChangeId - ) - Toggle(isOn: Binding( - get: { settings.confirmDragRebase }, - set: { settings.confirmDragRebase = $0 } - )) { - Text("Confirm before drag-to-rebase") - .jayjayFont(12) - } - Text("Any conflicts will appear inline after the rebase.") - .jayjayFont(11) - .foregroundStyle(.secondary) - } - } - ) - .frame(width: 360) - } - private var workspaceCreateSheet: some View { SheetContainer( title: "New Workspace", @@ -336,35 +296,4 @@ extension RepoContentView { viewModel.createBookmark(name: name, rev: rev) modal = nil } - - private func runDAGRebase(_ request: DAGRebaseRequest) { - viewModel.rebase( - request: request, - onSuccess: { repoViewModel, feedback in - let action = feedback.undoOperationId.map { operationId in - RepoToastAction(title: "Undo") { - repoViewModel.opRestore(opId: operationId) - } - } - showToast(feedback.message, action: action) - }, - onFailure: { _, message in - showToast(message) - } - ) - } - - private func rebaseSummaryRow(title: String, value: String, detail: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .jayjayFont(11, weight: .semibold) - .foregroundStyle(.secondary) - Text(value) - .jayjayFont(13, weight: .medium) - .lineLimit(1) - Text(String(detail.prefix(12))) - .jayjayFont(10, design: .monospaced) - .foregroundStyle(.tertiary) - } - } } diff --git a/shell/mac/Sources/JayJay/Repo/RepoContentView+RebasePresentation.swift b/shell/mac/Sources/JayJay/Repo/RepoContentView+RebasePresentation.swift new file mode 100644 index 0000000..1aad1a0 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/RepoContentView+RebasePresentation.swift @@ -0,0 +1,98 @@ +import SwiftUI + +extension RepoContentView { + func rebaseConfirmationSheet(request: DAGRebaseRequest) -> some View { + SheetContainer( + title: "\(request.placement.confirmationLabel) Change?", + subtitle: "\(String(request.sourceCommitId.prefix(12))) -> \(String(request.destCommitId.prefix(12)))", + cancelLabel: "Cancel", + confirmLabel: request.placement.confirmationLabel, + onCancel: { modal = nil }, + onConfirm: { + modal = nil + runDAGRebase(request) + }, + content: { + VStack(alignment: .leading, spacing: 12) { + rebaseSummaryRow( + title: "Change", + value: request.sourceLabel, + detail: request.sourceChangeId + ) + Label( + rebasePlacementSummary(request.placement), + systemImage: rebasePlacementIcon(request.placement) + ) + .jayjayFont(11) + .foregroundStyle(.secondary) + rebaseSummaryRow( + title: request.placement.targetRole, + value: request.destLabel, + detail: request.destChangeId + ) + Toggle(isOn: Binding( + get: { settings.confirmDragRebase }, + set: { settings.confirmDragRebase = $0 } + )) { + Text("Confirm before drag-to-rebase") + .jayjayFont(12) + } + Text("Any conflicts will appear inline after the rebase.") + .jayjayFont(11) + .foregroundStyle(.secondary) + } + } + ) + .frame(width: 360) + } + + func runDAGRebase(_ request: DAGRebaseRequest) { + viewModel.rebase( + request: request, + onSuccess: { repoViewModel, feedback in + let action = feedback.undoOperationId.map { operationId in + RepoToastAction(title: "Undo") { + repoViewModel.opRestore(opId: operationId) + } + } + showToast(feedback.message, action: action) + }, + onFailure: { _, message in + showToast(message) + } + ) + } + + private func rebaseSummaryRow(title: String, value: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .jayjayFont(11, weight: .semibold) + .foregroundStyle(.secondary) + Text(value) + .jayjayFont(13, weight: .medium) + .lineLimit(1) + Text(String(detail.prefix(12))) + .jayjayFont(10, design: .monospaced) + .foregroundStyle(.tertiary) + } + } + + private func rebasePlacementSummary(_ placement: DAGRebasePlacement) -> String { + switch placement { + case .onto: + "Will become a child of" + case .after: + "Will be inserted after; descendants move after it" + case .before: + "Will be inserted before; target and descendants move after it" + } + } + + private func rebasePlacementIcon(_ placement: DAGRebasePlacement) -> String { + switch placement { + case .onto: "arrow.down" + case .after: "arrow.down.to.line" + case .before: "arrow.up.to.line" + } + } +} diff --git a/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift b/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift index bad2e8b..64178a6 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift @@ -4,6 +4,7 @@ enum RepoModalState: Identifiable { case createBookmark(rev: String) case confirmAbandon(rev: String) case confirmRebase(request: DAGRebaseRequest) + case saveRevset case submoduleAttention case undoLog case bookmarkManager @@ -12,10 +13,11 @@ enum RepoModalState: Identifiable { var id: String { switch self { - case .createBookmark(let rev): "bookmark-\(rev)" - case .confirmAbandon(let rev): "abandon-\(rev)" - case .confirmRebase(let request): + case let .createBookmark(rev): "bookmark-\(rev)" + case let .confirmAbandon(rev): "abandon-\(rev)" + case let .confirmRebase(request): "rebase-\(request.sourceCommitId)-\(request.destCommitId)" + case .saveRevset: "save-revset" case .submoduleAttention: "submodule-attention" case .undoLog: "undo-log" case .bookmarkManager: "bookmark-manager" @@ -31,8 +33,8 @@ enum RepoAlertState: Identifiable { var id: String { switch self { - case .error(let message): "error-\(message)" - case .configWarning(let message): "config-warning-\(message)" + case let .error(message): "error-\(message)" + case let .configWarning(message): "config-warning-\(message)" } } } @@ -45,7 +47,7 @@ enum RepoOverlayState: Identifiable { switch self { case .loading: "loading" - case .toast(let state): + case let .toast(state): "toast-\(state.id)" } } diff --git a/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift b/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift index 1ba618d..fa994e8 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift @@ -10,6 +10,12 @@ extension RepoContentView { TextField("Revset expression", text: $revsetDraft) .textFieldStyle(.roundedBorder).jayjayFont(12, design: .monospaced) .onSubmit { applyRevset() } + Button("Save") { + revsetSaveName = suggestedRevsetName + modal = .saveRevset + } + .controlSize(.small) + .disabled(revsetDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) Button { applyRevset() } label: { Image(systemName: "arrow.right.circle.fill").foregroundStyle(.secondary) } @@ -25,12 +31,12 @@ extension RepoContentView { } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { - revsetChip("All", revset: "all()") - revsetChip("Mine", revset: "mine()") - revsetChip("Bookmarks", revset: "bookmarks()") - revsetChip("Trunk", revset: "trunk()") - revsetChip("Conflicts", revset: "conflict()") - revsetChip("Heads", revset: "heads(all())") + ForEach(SavedRevset.builtIns, id: \.id) { revset in + revsetChip(revset) + } + ForEach(settings.savedRevsets, id: \.id) { revset in + revsetChip(revset, saved: true) + } } } } @@ -57,7 +63,9 @@ extension RepoContentView { CommitBox( description: viewModel.workingCopyDescription, draft: $viewModel.commitDraft, - onCommit: { await viewModel.commit(message: $0, manageSubmodules: settings.enableGitSubmoduleSupport) }, + onCommit: { + await viewModel.commit(message: $0, manageSubmodules: settings.enableGitSubmoduleSupport) + }, onGenerateMessage: { await viewModel.generateCommitMessage() }, aiProvider: viewModel.aiProvider ) @@ -65,23 +73,68 @@ extension RepoContentView { } } - func revsetChip(_ label: String, revset: String) -> some View { + var revsetSaveSheet: some View { + SheetContainer( + title: "Save Revset", + cancelLabel: "Cancel", + confirmLabel: "Save", + confirmDisabled: revsetSaveName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onCancel: { modal = nil }, + onConfirm: { saveCurrentRevset() }, + content: { + TextField("Name", text: $revsetSaveName) + .textFieldStyle(.roundedBorder) + .frame(width: 220) + .onSubmit { saveCurrentRevset() } + Text(revsetDraft.trimmingCharacters(in: .whitespacesAndNewlines)) + .jayjayFont(11, design: .monospaced) + .foregroundStyle(.secondary) + .lineLimit(3) + .frame(width: 220, alignment: .leading) + } + ) + } + + private var suggestedRevsetName: String { + let expression = revsetDraft.trimmingCharacters(in: .whitespacesAndNewlines) + if let existing = SavedRevset.builtIns.first(where: { $0.expression == expression }) { + return existing.name + } + if let existing = settings.savedRevsets.first(where: { $0.expression == expression }) { + return existing.name + } + return "Custom Revset" + } + + func revsetChip(_ item: SavedRevset, saved: Bool = false) -> some View { Button { - revsetDraft = revset + revsetDraft = item.expression applyRevset() } label: { - Text(label) + Text(item.name) .jayjayFont(11, weight: .medium) .padding(.horizontal, 10) .padding(.vertical, 4) .background( - viewModel.revset == revset + viewModel.revset == item.expression ? AnyShapeStyle(Color.accentColor.opacity(0.2)) : AnyShapeStyle(Color.primary.opacity(0.06)), in: Capsule() ) } .buttonStyle(.plain) + .help(item.expression) + .contextMenu { + if saved { + Button(role: .destructive) { + settings.removeSavedRevset(id: item.id) + } label: { + Label("Delete Saved Revset", systemImage: "trash") + } + } else { + Text(item.expression) + } + } } func applyRevset() { @@ -96,6 +149,14 @@ extension RepoContentView { } } + private func saveCurrentRevset() { + guard !revsetSaveName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !revsetDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return } + settings.saveRevset(name: revsetSaveName, expression: revsetDraft) + modal = nil + } + var statusBar: some View { StatusBarView( leadingItems: statusBarLeadingItems, @@ -141,7 +202,7 @@ extension RepoContentView { icon: "exclamationmark.triangle.fill", text: "\(conflictedCount) conflicted" ) { - revsetDraft = "conflict()" + revsetDraft = "conflicts()" applyRevset() }) } diff --git a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift index f0af817..09d4ae4 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift @@ -94,6 +94,7 @@ struct RepoContentView: View { @State var showRevsetFilter = false @State var sidebarWidth: CGFloat = 360 @State var bookmarkCreateName = "" + @State var revsetSaveName = "" @State var modal: RepoModalState? @State var workspaceName = "" @State var activePane: ActivePane = .dag @@ -192,7 +193,8 @@ struct RepoContentView: View { activePane: $activePane, evologEntries: viewModel.evologEntries, evologRev: viewModel.evologRev, - onDismissEvolog: { viewModel.dismissEvolog() } + onDismissEvolog: { viewModel.dismissEvolog() }, + onRestoreEvologCommit: { viewModel.restoreEvologCommit(commitId: $0) } ) .frame(maxWidth: .infinity) } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift index 7ae50e3..a6a1887 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift @@ -110,12 +110,18 @@ extension RepoViewModel { perform { try $0.absorb(rev: rev) } } - func backout(rev: String) { - perform { try $0.backout(rev: rev) } + func revertChange(rev: String) { + perform { try $0.revertChange(rev: rev) } } func rebase(rev: String, dest: String) { - perform { try $0.rebase(rev: rev, dest: dest) } + rebase(rev: rev, dest: dest, placement: .onto) + } + + func rebase(rev: String, dest: String, placement: DAGRebasePlacement) { + perform { + try $0.rebaseWithPlacement(rev: rev, dest: dest, placement: placement) + } } func merge(parents: [String]) { diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift index 17535cf..11788da 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift @@ -26,4 +26,13 @@ extension RepoViewModel { evologRev = nil evologEntries = nil } + + func restoreEvologCommit(commitId: String) { + guard !commitId.isEmpty else { return } + dismissEvolog() + performMessaging(selecting: "@") { + try $0.restoreRevisionIntoWorkingCopy(rev: commitId) + return "Restored \(String(commitId.prefix(12))) into @." + } + } } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift index d61d54a..2fe515d 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Rebase.swift @@ -28,68 +28,75 @@ extension RepoViewModel { isRefreshingInFlight = true error = nil - runRepoTask( - { [requestedRevset = revset] repo in - let undoOperationId = try repo.opLog().first(where: { $0.isCurrent })?.id - try repo.rebase(rev: request.sourceRev, dest: request.destRev) - try repo.refreshWorkingCopy() + runRepoTask { [requestedRevset = revset] repo in + let undoOperationId = try repo.opLog().first(where: { $0.isCurrent })?.id + try repo.rebaseWithPlacement( + rev: request.sourceRev, + dest: request.destRev, + placement: request.placement + ) + try repo.refreshWorkingCopy() - let graphEntries = try repo.logGraph(revset: requestedRevset) - let log = graphEntries.map(\.change) - let bookmarks = try repo.listBookmarks() - let workspaces = (try? repo.workspaceList()) ?? [] - let selectedChange = try Self.loadSelectedDetail( - repo: repo, - log: log, - preferredRev: request.sourceChangeId - ) - let workingCopyDescription = log.first(where: { $0.isWorkingCopy })?.description ?? "" - let hadConflicts = graphEntries.contains(where: { - $0.change.changeId == request.sourceChangeId && $0.change.hasConflict - }) + let graphEntries = try repo.logGraph(revset: requestedRevset) + let log = graphEntries.map(\.change) + let bookmarks = try repo.listBookmarks() + let workspaces = (try? repo.workspaceList()) ?? [] + let selectedChange = try Self.loadSelectedDetail( + repo: repo, + log: log, + preferredRev: request.sourceChangeId + ) + let workingCopyDescription = log.first(where: { $0.isWorkingCopy })?.description ?? "" + let hadConflicts = graphEntries.contains(where: { + $0.change.changeId == request.sourceChangeId && $0.change.hasConflict + }) - return RepoRebaseRefreshResult( - graphEntries: graphEntries, - bookmarks: bookmarks, - workspaces: workspaces, - selectedChange: selectedChange, - workingCopyDescription: workingCopyDescription, - hadConflicts: hadConflicts, - undoOperationId: undoOperationId - ) - }, - onSuccess: { viewModel, result in - viewModel.successActionSignal += 1 - viewModel.graphEntries = result.graphEntries - viewModel.bookmarks = result.bookmarks - viewModel.workspaces = result.workspaces - viewModel.selectedChange = result.selectedChange - viewModel.selectedChangeId = result.selectedChange?.info.changeId - viewModel.workingCopyDescription = result.workingCopyDescription - viewModel.isLoading = false - viewModel.isRefreshingInFlight = false - viewModel.hasWorkingCopyChanges = false - viewModel.canLoadMore = Self.canLoadMore( - revset: viewModel.revset, - loadedCount: result.graphEntries.count - ) - viewModel.fetchPrInfo(bookmarks: result.selectedChange?.info.bookmarks ?? []) + return RepoRebaseRefreshResult( + graphEntries: graphEntries, + bookmarks: bookmarks, + workspaces: workspaces, + selectedChange: selectedChange, + workingCopyDescription: workingCopyDescription, + hadConflicts: hadConflicts, + undoOperationId: undoOperationId + ) + } onSuccess: { viewModel, result in + viewModel.successActionSignal += 1 + viewModel.graphEntries = result.graphEntries + viewModel.bookmarks = result.bookmarks + viewModel.workspaces = result.workspaces + viewModel.selectedChange = result.selectedChange + viewModel.selectedChangeId = result.selectedChange?.info.changeId + viewModel.workingCopyDescription = result.workingCopyDescription + viewModel.isLoading = false + viewModel.isRefreshingInFlight = false + viewModel.hasWorkingCopyChanges = false + viewModel.canLoadMore = Self.canLoadMore( + revset: viewModel.revset, + loadedCount: result.graphEntries.count + ) + viewModel.fetchPrInfo(bookmarks: result.selectedChange?.info.bookmarks ?? []) - onSuccess(viewModel, RepoRebaseFeedback( - message: Self.rebaseMessage(for: request, hadConflicts: result.hadConflicts), - undoOperationId: result.undoOperationId - )) - }, - onFailure: { viewModel, error in - viewModel.isLoading = false - viewModel.isRefreshingInFlight = false - onFailure(viewModel, error.friendlyDescription) - } - ) + onSuccess(viewModel, RepoRebaseFeedback( + message: Self.rebaseMessage(for: request, hadConflicts: result.hadConflicts), + undoOperationId: result.undoOperationId + )) + } onFailure: { viewModel, error in + viewModel.isLoading = false + viewModel.isRefreshingInFlight = false + onFailure(viewModel, error.friendlyDescription) + } } private static func rebaseMessage(for request: DAGRebaseRequest, hadConflicts: Bool) -> String { - let base = "Rebased \(request.sourceLabel) onto \(request.destLabel)." + let base = switch request.placement { + case .onto: + "Rebased \(request.sourceLabel) onto \(request.destLabel)." + case .after: + "Inserted \(request.sourceLabel) after \(request.destLabel)." + case .before: + "Inserted \(request.sourceLabel) before \(request.destLabel)." + } guard hadConflicts else { return base } return "\(base) Conflicts need resolution." } diff --git a/shell/mac/Sources/JayJay/Shared/ChangeActions.swift b/shell/mac/Sources/JayJay/Shared/ChangeActions.swift index 32ac5d5..e1baba7 100644 --- a/shell/mac/Sources/JayJay/Shared/ChangeActions.swift +++ b/shell/mac/Sources/JayJay/Shared/ChangeActions.swift @@ -39,8 +39,9 @@ protocol DAGActions: AnyObject { func squash(rev: String) func squash(rev: String, into: String) func absorb(rev: String) - func backout(rev: String) + func revertChange(rev: String) func rebase(rev: String, dest: String) + func rebase(rev: String, dest: String, placement: DAGRebasePlacement) func abandon(rev: String) func compareWith(from: String, to: String) func showEvolog(rev: String) diff --git a/shell/mac/Sources/JayJay/Shared/CommandPalette+RawJJ.swift b/shell/mac/Sources/JayJay/Shared/CommandPalette+RawJJ.swift new file mode 100644 index 0000000..88a9dfb --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/CommandPalette+RawJJ.swift @@ -0,0 +1,162 @@ +import AppKit +import JayJayCore +import SwiftUI + +extension PaletteRoot { + var isJJ: Bool { + jjCommandBody(query: query) != nil + } + + var jjCmd: String { + jjCommandBody(query: query) ?? "" + } + + @ViewBuilder + var jjSection: some View { + if isRunning { + ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let result = jjResult { + jjResultView(result) + } else if let jjError { + VStack(alignment: .leading, spacing: 10) { + Label(jjError, systemImage: "exclamationmark.triangle") + .font(.system(size: 12)) + .foregroundStyle(.red) + jjDiscovery + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(12) + } else { + jjPreview + } + } + + var jjDiscovery: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Raw jj commands run in this repository. Use ↑/↓ for history.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + ForEach(["status", "log -r @", "diff --stat", "op log"], id: \.self) { suggestion in + Button("jj \(suggestion)") { query = "jj \(suggestion)" } + .controlSize(.small) + } + } + if !history.isEmpty { + Text("Recent") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + ForEach(history.prefix(5), id: \.self) { command in + Button { query = "jj \(command)" } label: { + HStack { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + Text("jj \(command)") + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + Spacer() + } + } + .buttonStyle(.plain) + } + } + } + } + + func execute() { + if isJJ { + executeJJ() + } else if selectedIndex < filtered.count { + filtered[selectedIndex].action() + onDismiss() + } + } + + func recallHistory(older: Bool) { + let next = CommandPaletteHistory.recall( + history: history, + historyIndex: historyIndex, + older: older + ) + guard let next else { return } + historyIndex = next.historyIndex + isRecallingHistory = true + query = next.query + } + + private func jjResultView(_ result: JjCommandRun) -> some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Image(systemName: result.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(result.success ? .green : .red) + Text(result.display) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(1) + Spacer() + Text(result.success ? "exit 0" : "exit \(result.exitCode)") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + Button("Copy Output") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result.output, forType: .string) + } + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + Divider() + ScrollView { + Text(result.output) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + } + } + + private var jjPreview: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("jj \(jjCmd)") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + if !jjCmd.isEmpty { + Text("Enter ↵") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + jjDiscovery + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(12) + } + + private func executeJJ() { + guard !jjCmd.isEmpty else { return } + guard parseJjCommandArgs(command: jjCmd) != nil else { + jjError = "Unclosed quote in jj command." + return + } + isRunning = true + jjResult = nil + jjError = nil + let path = repoPath + let command = jjCmd + Task.detached { + let result = Result { try runJjCommandInRepoPath(repoPath: path, command: command) } + await MainActor.run { + switch result { + case let .success(commandResult): + jjResult = commandResult + history = CommandPaletteHistory.record(command) + case let .failure(error): + jjError = error.localizedDescription + } + isRunning = false + } + } + } +} diff --git a/shell/mac/Sources/JayJay/Shared/CommandPalette.swift b/shell/mac/Sources/JayJay/Shared/CommandPalette.swift index 54de3c3..daea0c5 100644 --- a/shell/mac/Sources/JayJay/Shared/CommandPalette.swift +++ b/shell/mac/Sources/JayJay/Shared/CommandPalette.swift @@ -1,105 +1,27 @@ -import AppKit import JayJayCore import SwiftUI -struct CommandPaletteItem: Identifiable { - let id = UUID() - let title: String - let icon: String - let category: String - let action: () -> Void -} - -// MARK: - NSPanel - -final class CommandPalettePanel: NSPanel { - init() { - super.init( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), - styleMask: [.nonactivatingPanel, .fullSizeContentView], - backing: .buffered, - defer: true - ) - titleVisibility = .hidden - titlebarAppearsTransparent = true - isMovableByWindowBackground = true - level = .floating - isOpaque = false - backgroundColor = .clear - hidesOnDeactivate = true - } - - func show(items: [CommandPaletteItem], repoPath: String) { - // Create a fresh view controller each time (guarantees fresh @State) - let vc = NSHostingController(rootView: PaletteRoot( - items: items, - repoPath: repoPath, - onDismiss: { [weak self] in self?.dismiss() } - )) - contentViewController = vc - // Inherit dark/light appearance from the parent window - if let parentAppearance = NSApp.windows.first(where: { $0.isKeyWindow && $0 !== self })?.appearance { - appearance = parentAppearance - } else { - appearance = NSApp.effectiveAppearance - } - setContentSize(NSSize(width: 480, height: 300)) - - // Center on parent window - let parentFrame = NSApp.windows.first(where: { $0.isKeyWindow && $0 !== self })?.frame - ?? NSScreen.main?.frame ?? .zero - let x = parentFrame.midX - 240 - let y = parentFrame.midY + 40 - setFrameOrigin(NSPoint(x: x, y: y)) - makeKeyAndOrderFront(nil) - } - - func dismiss() { - orderOut(nil) - contentViewController = nil - } - - override func cancelOperation(_ sender: Any?) { - dismiss() - } - - override var canBecomeKey: Bool { - true - } -} - -// MARK: - Root view (owns @State, destroyed on dismiss) - -private struct PaletteRoot: View { +struct PaletteRoot: View { let items: [CommandPaletteItem] let repoPath: String let onDismiss: () -> Void - @State private var query = "" - @State private var selectedIndex = 0 - @State private var jjOutput: String? - @State private var isRunning = false - - /// One of these prefixes means the rest of `query` is a raw jj CLI invocation. - private static let jjPrefixes = ["jj ", "!"] + @State var query = "" + @State var selectedIndex = 0 + @State var jjResult: JjCommandRun? + @State var jjError: String? + @State var isRunning = false + @State var history = CommandPaletteHistory.load() + @State var historyIndex: Int? + @State var isRecallingHistory = false - private var isJJ: Bool { - query == "jj" || Self.jjPrefixes.contains(where: query.hasPrefix) - } - - private var jjCmd: String { - let stripped = Self.jjPrefixes - .first(where: query.hasPrefix) - .map { String(query.dropFirst($0.count)) } - ?? (query == "jj" ? "" : query) - return stripped.trimmingCharacters(in: .whitespaces) - } - - private var filtered: [CommandPaletteItem] { + var filtered: [CommandPaletteItem] { guard !isJJ else { return [] } guard !query.isEmpty else { return items } - let q = query.lowercased() - return items.filter { $0.title.lowercased().contains(q) || $0.category.lowercased().contains(q) } + return items.filter { + $0.title.lowercased().contains(query.lowercased()) + || $0.category.lowercased().contains(query.lowercased()) + } } var body: some View { @@ -108,7 +30,7 @@ private struct PaletteRoot: View { Image(systemName: isJJ ? "terminal" : "magnifyingglass") .foregroundStyle(.secondary) .frame(width: 16) - TextField("Type a command or 'jj ' / '!' for jj CLI...", text: $query) + TextField("Search commands, type `jj status`, or use `!status`", text: $query) .textFieldStyle(.plain) .font(.system(size: 14)) .accessibilityIdentifier(AID.Palette.textField) @@ -120,13 +42,23 @@ private struct PaletteRoot: View { resultArea } - .frame(width: 480, height: 300) + .frame(width: 520, height: 360) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) - .onKeyPress(.upArrow) { move(-1) + .onKeyPress(.upArrow) { + if isJJ { + recallHistory(older: true) + } else { + move(-1) + } return .handled } - .onKeyPress(.downArrow) { move(1) + .onKeyPress(.downArrow) { + if isJJ { + recallHistory(older: false) + } else { + move(1) + } return .handled } .onKeyPress { press in @@ -140,11 +72,19 @@ private struct PaletteRoot: View { } return .ignored } - .onKeyPress(.escape) { onDismiss() + .onKeyPress(.escape) { + onDismiss() return .handled } - .onChange(of: query) { selectedIndex = 0 - jjOutput = nil + .onChange(of: query) { + selectedIndex = 0 + jjResult = nil + jjError = nil + if isRecallingHistory { + isRecallingHistory = false + } else { + historyIndex = nil + } } } @@ -182,61 +122,8 @@ private struct PaletteRoot: View { } } - @ViewBuilder - private var jjSection: some View { - if let output = jjOutput { - ScrollView { - Text(output) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - } - } else if isRunning { - ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - HStack { - Text("jj \(jjCmd)").font(.system(size: 12, design: .monospaced)).foregroundStyle(.secondary) - Spacer() - if !jjCmd.isEmpty { Text("Enter ↵").font(.system(size: 10)).foregroundStyle(.tertiary) } - } - .padding(12) - Spacer() - } - } - private func move(_ delta: Int) { guard !filtered.isEmpty else { return } selectedIndex = max(0, min(filtered.count - 1, selectedIndex + delta)) } - - private func execute() { - if isJJ { - guard !jjCmd.isEmpty else { return } - isRunning = true - let args = jjCmd.components(separatedBy: " ") - let path = repoPath - Task.detached { - let status = checkJjEnvironment() - let proc = Process() - proc.executableURL = URL(fileURLWithPath: status.path) - proc.arguments = args - proc.currentDirectoryURL = URL(fileURLWithPath: path) - let pipe = Pipe() - proc.standardOutput = pipe - proc.standardError = pipe - try? proc.run() - proc.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "(no output)" - await MainActor.run { - jjOutput = output - isRunning = false - } - } - } else if !filtered.isEmpty, selectedIndex < filtered.count { - filtered[selectedIndex].action() - onDismiss() - } - } } diff --git a/shell/mac/Sources/JayJay/Shared/CommandPalettePanel.swift b/shell/mac/Sources/JayJay/Shared/CommandPalettePanel.swift new file mode 100644 index 0000000..e2ba7d3 --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/CommandPalettePanel.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI + +struct CommandPaletteItem: Identifiable { + let id = UUID() + let title: String + let icon: String + let category: String + let action: () -> Void +} + +final class CommandPalettePanel: NSPanel { + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), + styleMask: [.nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: true + ) + titleVisibility = .hidden + titlebarAppearsTransparent = true + isMovableByWindowBackground = true + level = .floating + isOpaque = false + backgroundColor = .clear + hidesOnDeactivate = true + } + + func show(items: [CommandPaletteItem], repoPath: String) { + let vc = NSHostingController(rootView: PaletteRoot( + items: items, + repoPath: repoPath, + onDismiss: { [weak self] in self?.dismiss() } + )) + contentViewController = vc + if let parentAppearance = NSApp.windows.first(where: { $0.isKeyWindow && $0 !== self })?.appearance { + appearance = parentAppearance + } else { + appearance = NSApp.effectiveAppearance + } + setContentSize(NSSize(width: 520, height: 360)) + + let parentFrame = NSApp.windows.first(where: { $0.isKeyWindow && $0 !== self })?.frame + ?? NSScreen.main?.frame ?? .zero + setFrameOrigin(NSPoint(x: parentFrame.midX - 260, y: parentFrame.midY + 40)) + makeKeyAndOrderFront(nil) + } + + func dismiss() { + orderOut(nil) + contentViewController = nil + } + + override func cancelOperation(_ sender: Any?) { + dismiss() + } + + override var canBecomeKey: Bool { + true + } +} diff --git a/shell/mac/Sources/JayJay/Shared/CommandPaletteSupport.swift b/shell/mac/Sources/JayJay/Shared/CommandPaletteSupport.swift new file mode 100644 index 0000000..9016d6d --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/CommandPaletteSupport.swift @@ -0,0 +1,41 @@ +import Foundation +import JayJayCore + +enum CommandPaletteHistory { + private static let key = "jayjay.commandPalette.jjHistory" + private static let limit = 20 + + struct Recall { + let query: String + let historyIndex: Int? + } + + static func load(defaults: UserDefaults = .standard) -> [String] { + defaults.stringArray(forKey: key) ?? [] + } + + static func record(_ command: String, defaults: UserDefaults = .standard) -> [String] { + let values = recordJjCommandHistory( + command: command, + existing: load(defaults: defaults), + limit: UInt32(limit) + ) + defaults.set(values, forKey: key) + return values + } + + static func recall(history: [String], historyIndex: Int?, older: Bool) -> Recall? { + guard !history.isEmpty else { return nil } + let nextIndex: Int? = if older { + min((historyIndex ?? -1) + 1, history.count - 1) + } else if let historyIndex, historyIndex > 0 { + historyIndex - 1 + } else { + nil + } + return Recall( + query: nextIndex.map { "jj \(history[$0])" } ?? "jj ", + historyIndex: nextIndex + ) + } +} diff --git a/shell/mac/Tests/JayJayTests/AppSettingsRevsetTests.swift b/shell/mac/Tests/JayJayTests/AppSettingsRevsetTests.swift new file mode 100644 index 0000000..adf9fce --- /dev/null +++ b/shell/mac/Tests/JayJayTests/AppSettingsRevsetTests.swift @@ -0,0 +1,55 @@ +@testable import JayJay +import XCTest + +final class AppSettingsRevsetTests: XCTestCase { + func testSavesAndReloadsRevsets() { + let defaults = makeDefaults() + let settings = AppSettings(defaults: defaults) + + settings.saveRevset(name: "Mine touching docs", expression: "mine() & files('docs/**')") + + let reloaded = AppSettings(defaults: defaults) + XCTAssertEqual(reloaded.savedRevsets.count, 1) + XCTAssertEqual(reloaded.savedRevsets.first?.name, "Mine touching docs") + XCTAssertEqual(reloaded.savedRevsets.first?.expression, "mine() & files('docs/**')") + } + + func testSavingSameExpressionReplacesExistingRevset() { + let settings = AppSettings(defaults: makeDefaults()) + + settings.saveRevset(name: "Old", expression: "heads(all())") + settings.saveRevset(name: "New", expression: "heads(all())") + + XCTAssertEqual(settings.savedRevsets.count, 1) + XCTAssertEqual(settings.savedRevsets.first?.name, "New") + } + + func testRemoveSavedRevset() throws { + let settings = AppSettings(defaults: makeDefaults()) + settings.saveRevset(name: "Scratch", expression: "@") + let id = try XCTUnwrap(settings.savedRevsets.first?.id) + + settings.removeSavedRevset(id: id) + + XCTAssertTrue(settings.savedRevsets.isEmpty) + } + + func testSavesAndReloadsEvologDisplayPreferences() { + let defaults = makeDefaults() + let settings = AppSettings(defaults: defaults) + + settings.evologHideSnapshots = true + settings.evologCollapseSnapshotRuns = false + + let reloaded = AppSettings(defaults: defaults) + XCTAssertTrue(reloaded.evologHideSnapshots) + XCTAssertFalse(reloaded.evologCollapseSnapshotRuns) + } + + private func makeDefaults() -> UserDefaults { + let suiteName = "dev.hewig.jayjay.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} diff --git a/shell/mac/Tests/JayJayTests/CommandPaletteSupportTests.swift b/shell/mac/Tests/JayJayTests/CommandPaletteSupportTests.swift new file mode 100644 index 0000000..73b0596 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/CommandPaletteSupportTests.swift @@ -0,0 +1,72 @@ +@testable import JayJay +import JayJayCore +import XCTest + +final class CommandPaletteSupportTests: XCTestCase { + func testParsesQuotedJjArguments() { + XCTAssertEqual( + parseJjCommandArgs(command: #"log -r "description(exact:'fix bug')" --limit 5"#), + ["log", "-r", "description(exact:'fix bug')", "--limit", "5"] + ) + } + + func testPreservesEmptyQuotedJjArguments() { + XCTAssertEqual( + parseJjCommandArgs(command: #"describe -m """#), + ["describe", "-m", ""] + ) + } + + func testRejectsUnclosedQuote() { + XCTAssertNil(parseJjCommandArgs(command: #"log -r "mine()"#)) + } + + func testHistoryDedupesAndKeepsNewestFirst() { + let defaults = makeDefaults() + + _ = CommandPaletteHistory.record("status", defaults: defaults) + _ = CommandPaletteHistory.record("log -r @", defaults: defaults) + let history = CommandPaletteHistory.record("status", defaults: defaults) + + XCTAssertEqual(Array(history.prefix(2)), ["status", "log -r @"]) + } + + func testHistoryRecallWalksOlderAndNewerEntries() { + let history = ["status", "log -r @", "diff --stat"] + + let first = CommandPaletteHistory.recall(history: history, historyIndex: nil, older: true) + XCTAssertEqual(first?.query, "jj status") + XCTAssertEqual(first?.historyIndex, 0) + + let second = CommandPaletteHistory.recall( + history: history, + historyIndex: first?.historyIndex, + older: true + ) + XCTAssertEqual(second?.query, "jj log -r @") + XCTAssertEqual(second?.historyIndex, 1) + + let newer = CommandPaletteHistory.recall( + history: history, + historyIndex: second?.historyIndex, + older: false + ) + XCTAssertEqual(newer?.query, "jj status") + XCTAssertEqual(newer?.historyIndex, 0) + + let liveQuery = CommandPaletteHistory.recall( + history: history, + historyIndex: newer?.historyIndex, + older: false + ) + XCTAssertEqual(liveQuery?.query, "jj ") + XCTAssertNil(liveQuery?.historyIndex) + } + + private func makeDefaults() -> UserDefaults { + let suiteName = "dev.hewig.jayjay.palette.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} diff --git a/shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift b/shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift index ac559e0..e57738f 100644 --- a/shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift +++ b/shell/mac/Tests/JayJayTests/DAGRebaseGesturePolicyTests.swift @@ -107,6 +107,7 @@ final class DAGRebaseGesturePolicyTests: XCTestCase { rebaseDrag: makeDragState(phase: .dragging), previewTargetCommitId: nil, hoveredCommitId: "base-commit", + hoveredPlacement: .onto, entries: [source, ancestor] ) @@ -114,6 +115,7 @@ final class DAGRebaseGesturePolicyTests: XCTestCase { XCTAssertEqual(request?.destCommitId, "base-commit") XCTAssertEqual(request?.destRev, "base-change") XCTAssertEqual(request?.destLabel, "main") + XCTAssertEqual(request?.placement, .onto) } func testAllowsImmutableTarget() { @@ -129,12 +131,62 @@ final class DAGRebaseGesturePolicyTests: XCTestCase { rebaseDrag: makeDragState(phase: .dragging), previewTargetCommitId: nil, hoveredCommitId: "target-commit", + hoveredPlacement: .after, entries: [immutableTarget] ) XCTAssertEqual(request?.destCommitId, "target-commit") XCTAssertEqual(request?.destRev, "target-change") XCTAssertEqual(request?.destLabel, "main") + XCTAssertEqual(request?.placement, .after) + } + + func testRejectsInsertBeforeImmutableTarget() { + let immutableTarget = makeEntry( + changeId: "target-change", + commitId: "target-commit", + description: "", + isImmutable: true, + bookmarks: ["main"] + ) + + let request = DAGRebaseGesturePolicy.dropRequest( + rebaseDrag: makeDragState(phase: .dragging), + previewTargetCommitId: nil, + hoveredCommitId: "target-commit", + hoveredPlacement: .before, + entries: [immutableTarget] + ) + + XCTAssertNil(request) + } + + func testPlacementZones() { + let frame = CGRect(x: 0, y: 10, width: 120, height: 90) + + XCTAssertEqual(DAGRebaseGesturePolicy.placement(location: CGPoint(x: 20, y: 20), rowFrame: frame), .before) + XCTAssertEqual(DAGRebaseGesturePolicy.placement(location: CGPoint(x: 20, y: 55), rowFrame: frame), .onto) + XCTAssertEqual(DAGRebaseGesturePolicy.placement(location: CGPoint(x: 20, y: 95), rowFrame: frame), .after) + } + + func testValidPlacementRejectsImmutableTopBand() { + let frame = CGRect(x: 0, y: 10, width: 120, height: 90) + + XCTAssertNil(DAGRebaseGesturePolicy.validPlacement( + location: CGPoint(x: 20, y: 20), + rowFrame: frame, + targetIsImmutable: true + )) + XCTAssertEqual(DAGRebaseGesturePolicy.validPlacement( + location: CGPoint(x: 20, y: 55), + rowFrame: frame, + targetIsImmutable: true + ), .onto) + XCTAssertEqual(DAGRebaseGesturePolicy.validPlacement( + location: CGPoint(x: 20, y: 95), + rowFrame: frame, + targetIsImmutable: true + ), .after) } private func makeDragState( @@ -150,7 +202,8 @@ final class DAGRebaseGesturePolicyTests: XCTestCase { armedAt: phase == .pressing ? nil : Date(timeIntervalSinceReferenceDate: 10), phase: phase, location: startLocation, - hoveredCommitId: nil + hoveredCommitId: nil, + hoveredPlacement: nil ) } diff --git a/shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift b/shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift index d8178bc..9a91bf8 100644 --- a/shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift +++ b/shell/mac/Tests/JayJayTests/DAGRowViewModelTests.swift @@ -57,7 +57,7 @@ final class DAGRowViewModelTests: XCTestCase { XCTAssertTrue(viewModel.isRebaseSource) XCTAssertTrue(viewModel.isRebaseArmed) - XCTAssertEqual(viewModel.dragTargetText, "Drag to choose a new parent") + XCTAssertEqual(viewModel.dragTargetText, "Drag to choose onto, before, or after") XCTAssertNotEqual(viewModel.wiggleAngle(at: armedAt.addingTimeInterval(0.2)), 0) } @@ -158,7 +158,8 @@ final class DAGRowViewModelTests: XCTestCase { armedAt: armedAt, phase: phase, location: .zero, - hoveredCommitId: "target-commit" + hoveredCommitId: "target-commit", + hoveredPlacement: .onto ) } } diff --git a/shell/mac/Tests/JayJayTests/DiffEditSelectionTests.swift b/shell/mac/Tests/JayJayTests/DiffEditSelectionTests.swift new file mode 100644 index 0000000..33c9167 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/DiffEditSelectionTests.swift @@ -0,0 +1,51 @@ +@testable import JayJay +import JayJayCore +import XCTest + +final class DiffEditSelectionTests: XCTestCase { + func testBuildSelectionsIncludesEveryLoadedFile() { + let first = loadedFile(path: "a.txt", content: "one\n") + let second = loadedFile(path: "b.txt", content: "two\n") + let loadedFiles = [ + first.hunk.path: first, + second.hunk.path: second + ] + let selected = loadedFiles.mapValues(\.changedLineSet) + + let selections = buildDiffEditSelections( + loadedFiles: loadedFiles, + selectedChangedLinesByPath: selected, + destination: .newChild + ) + + XCTAssertEqual(Set(selections.map(\.path)), ["a.txt", "b.txt"]) + } + + private func loadedFile(path: String, content: String) -> DiffEditLoadedFile { + let hunk = DiffHunk( + path: path, + oldPath: nil, + oldContent: "", + newContent: content, + oldPreview: nil, + newPreview: nil, + hunkType: .added, + reviewIdentity: "identity-\(path)" + ) + let diff = FileDiff( + path: path, + language: "text", + lines: [ + DiffLine( + oldLineNo: nil, + newLineNo: 1, + style: .added, + spans: [DiffSpan(text: content.trimmingCharacters(in: .newlines), style: .added, token: .plain)], + noEofNewline: false + ) + ], + whitespaceOnlyHidden: false + ) + return DiffEditLoadedFile(hunk: hunk, oldContent: "", newContent: content, diff: diff) + } +} diff --git a/shell/mac/Tests/JayJayTests/EvologDisplayTests.swift b/shell/mac/Tests/JayJayTests/EvologDisplayTests.swift new file mode 100644 index 0000000..31f3221 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/EvologDisplayTests.swift @@ -0,0 +1,18 @@ +@testable import JayJay +import XCTest + +final class EvologDisplayTests: XCTestCase { + func testOperationLabelMapsKnownOperationsInUiLayer() { + XCTAssertEqual(EvologDisplay.operationLabel("snapshot working copy 123"), "snapshot") + XCTAssertEqual(EvologDisplay.operationLabel("describe commit abc"), "describe") + XCTAssertEqual(EvologDisplay.operationLabel("rebase commit abc"), "rebase") + XCTAssertEqual(EvologDisplay.operationLabel("squash commits abc"), "squash") + XCTAssertEqual(EvologDisplay.operationLabel("split commit abc"), "split") + XCTAssertEqual(EvologDisplay.operationLabel("new empty commit"), "new") + XCTAssertEqual(EvologDisplay.operationLabel(""), "rewrite") + } + + func testOperationLabelPreservesUnknownOperation() { + XCTAssertEqual(EvologDisplay.operationLabel("custom operation"), "custom operation") + } +} diff --git a/shell/mac/Tests/JayJayTests/EvologViewModelTests.swift b/shell/mac/Tests/JayJayTests/EvologViewModelTests.swift new file mode 100644 index 0000000..f95d299 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/EvologViewModelTests.swift @@ -0,0 +1,85 @@ +@testable import JayJay +import JayJayCore +import XCTest + +final class EvologViewModelTests: XCTestCase { + func testCollapsesConsecutiveSnapshotRuns() { + let viewModel = makeViewModel(entries: [ + entry(commitId: "a", operation: "snapshot working copy"), + entry(commitId: "b", operation: "snapshot working copy"), + entry(commitId: "c", operation: "describe commit c"), + entry(commitId: "d", operation: "snapshot working copy") + ]) + + XCTAssertEqual(viewModel.visibleRows.count, 3) + XCTAssertEqual(viewModel.visibleRows[0].entries.count, 2) + XCTAssertTrue(viewModel.visibleRows[0].isSnapshotRun) + XCTAssertEqual(viewModel.visibleRows[1].primary.commitId, "c") + XCTAssertFalse(viewModel.visibleRows[2].isSnapshotRun) + } + + func testHideSnapshotsRemovesSnapshotRows() { + let viewModel = makeViewModel(entries: [ + entry(commitId: "a", operation: "snapshot working copy"), + entry(commitId: "b", operation: "describe commit b"), + entry(commitId: "c", operation: "snapshot working copy") + ]) + + viewModel.setHideSnapshots(true) + + XCTAssertEqual(viewModel.visibleRows.map(\.primary.commitId), ["b"]) + XCTAssertEqual(viewModel.hiddenSnapshotCount, 2) + } + + func testNormalizeSelectionClearsHiddenSnapshotSelection() { + let viewModel = makeViewModel(entries: [ + entry(commitId: "a", operation: "snapshot working copy"), + entry(commitId: "b", operation: "describe commit b") + ]) + viewModel.selectedIndex = 0 + + viewModel.setHideSnapshots(true) + + XCTAssertNil(viewModel.selectedIndex) + XCTAssertNil(viewModel.interdiffDetail) + } + + func testNormalizeSelectionClearsNegativeSelection() { + let viewModel = makeViewModel(entries: [ + entry(commitId: "a", operation: "describe commit a") + ]) + viewModel.selectedIndex = -1 + + viewModel.normalizeSelection() + + XCTAssertNil(viewModel.selectedIndex) + } + + func testNormalizeSelectionMovesCollapsedSnapshotSelectionToVisiblePrimary() { + let viewModel = makeViewModel(entries: [ + entry(commitId: "a", operation: "snapshot working copy"), + entry(commitId: "b", operation: "snapshot working copy"), + entry(commitId: "c", operation: "describe commit c") + ]) + viewModel.collapseSnapshotRuns = false + viewModel.selectedIndex = 1 + + viewModel.setCollapseSnapshotRuns(true) + + XCTAssertEqual(viewModel.selectedIndex, 0) + } + + private func makeViewModel(entries: [EvologEntry]) -> EvologViewModel { + EvologViewModel(entries: entries, changeId: "change", repo: nil, diffStore: DiffStore()) + } + + private func entry(commitId: String, operation: String) -> EvologEntry { + EvologEntry( + changeId: "change", + commitId: commitId, + timestampMillis: 0, + operation: operation, + description: "" + ) + } +} From f97edeb7e35914b54210f43ec36df0c68f235027 Mon Sep 17 00:00:00 2001 From: hewigovens <360470+hewigovens@users.noreply.github.com> Date: Mon, 18 May 2026 08:37:13 +0900 Subject: [PATCH 2/3] deps(rust): bump gpui_platform to v1.1.7 --- Cargo.lock | 131 +++++++++++++++++++----------------------- shell/gpui/Cargo.toml | 6 +- 2 files changed, 62 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2af3336..e3eb3b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -118,7 +118,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -730,6 +730,16 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1084,7 +1094,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "indexmap", "rustc-hash 2.1.2", @@ -1508,7 +1518,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "proc-macro2", "quote", @@ -1564,7 +1574,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1753,7 +1763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3277,7 +3287,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -3316,7 +3326,6 @@ dependencies = [ "mach2", "media", "metal", - "naga 29.0.3", "num_cpus", "objc", "parking", @@ -3360,7 +3369,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "bytemuck", @@ -3391,7 +3400,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "async-task", @@ -3434,7 +3443,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3445,7 +3454,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "console_error_panic_hook", "gpui", @@ -3458,18 +3467,17 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ - "derive_more", - "gpui_util", "schemars", "serde", + "smol_str", ] [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "log", @@ -3478,7 +3486,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3502,7 +3510,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "bytemuck", @@ -3529,7 +3537,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "collections", @@ -3553,9 +3561,9 @@ dependencies = [ [[package]] name = "grid" -version = "0.18.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" [[package]] name = "half" @@ -3723,7 +3731,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "async-compression", @@ -4141,7 +4149,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4684,7 +4692,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "bindgen", @@ -4798,31 +4806,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "naga" -version = "29.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" -dependencies = [ - "arrayvec", - "bit-set 0.9.1", - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.16.1", - "hexf-parse", - "indexmap", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "thiserror 2.0.18", - "unicode-ident", -] - [[package]] name = "nanorand" version = "0.7.0" @@ -4953,7 +4936,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5339,7 +5322,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "collections", "serde", @@ -6023,7 +6006,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "derive_refineable", ] @@ -6181,7 +6164,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6194,7 +6177,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6286,7 +6269,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "async-task", "backtrace", @@ -6704,6 +6687,10 @@ name = "smol_str" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] [[package]] name = "spin" @@ -6748,7 +6735,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6829,7 +6816,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "heapless 0.9.3", "log", @@ -6990,9 +6977,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.9.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004" +checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" dependencies = [ "arrayvec", "grid", @@ -7028,7 +7015,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7658,7 +7645,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7991,7 +7978,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "async-fs", @@ -8030,7 +8017,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "perf", "quote", @@ -8349,7 +8336,7 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga 29.0.0", + "naga", "parking_lot", "portable-atomic", "profiling", @@ -8379,7 +8366,7 @@ dependencies = [ "hashbrown 0.16.1", "indexmap", "log", - "naga 29.0.0", + "naga", "once_cell", "parking_lot", "portable-atomic", @@ -8444,7 +8431,7 @@ dependencies = [ "libc", "libloading", "log", - "naga 29.0.0", + "naga", "ndk-sys", "objc2", "objc2-core-foundation", @@ -8477,7 +8464,7 @@ name = "wgpu-naga-bridge" version = "29.0.0" source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" dependencies = [ - "naga 29.0.0", + "naga", "wgpu-types", ] @@ -8528,7 +8515,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9013,7 +9000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9435,7 +9422,7 @@ checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "anyhow", "chrono", @@ -9452,7 +9439,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" dependencies = [ "tracing", "tracing-subscriber", @@ -9463,7 +9450,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?tag=v1.0.1#ad3e097279b3a5c04b273a4f2f6c6ed1729ea0c8" +source = "git+https://github.com/zed-industries/zed?tag=v1.1.7#c17f25cd782edffab64b7c4167abfa44f865b125" [[package]] name = "zune-core" diff --git a/shell/gpui/Cargo.toml b/shell/gpui/Cargo.toml index 80246c1..91562ad 100644 --- a/shell/gpui/Cargo.toml +++ b/shell/gpui/Cargo.toml @@ -12,8 +12,8 @@ path = "src/main.rs" [dependencies] jayjay-core = { path = "../../crates/jayjay-core" } -gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.0.1" } -gpui_platform = { git = "https://github.com/zed-industries/zed", tag = "v1.0.1", features = ["font-kit", "runtime_shaders"] } +gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.1.7" } +gpui_platform = { git = "https://github.com/zed-industries/zed", tag = "v1.1.7", features = ["font-kit", "runtime_shaders"] } chrono = { workspace = true } font-kit = "0.14" ureq = "3" @@ -26,5 +26,5 @@ serde = { workspace = true } toml = { workspace = true } [dev-dependencies] -gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.0.1", features = ["test-support"] } +gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.1.7", features = ["test-support"] } jj-test-fixtures = { path = "../../crates/jj-test-fixtures" } From 60da580d8ac7e9091fa33ad07eaeaea72ab2434b Mon Sep 17 00:00:00 2001 From: hewigovens <360470+hewigovens@users.noreply.github.com> Date: Mon, 18 May 2026 08:37:13 +0900 Subject: [PATCH 3/3] chore: harden release archive packaging --- AGENTS.md | 2 +- scripts/package-release-zip.sh | 47 +++++++++++++++++++++ scripts/verify-release-archive.sh | 70 +++++++++++++++++++++++++++++++ shell/release.just | 19 +++++---- 4 files changed, 129 insertions(+), 9 deletions(-) create mode 100755 scripts/package-release-zip.sh create mode 100755 scripts/verify-release-archive.sh diff --git a/AGENTS.md b/AGENTS.md index da6f1eb..ccf987c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Releases are not complete after `just release` alone. The full release flow is: 1. Bump version and build number in **all four** sources: `shell/mac/project.yml`, `shell/mac/JayJay.xcodeproj/project.pbxproj`, `crates/jayjay-cli/Cargo.toml`, and `shell/justfile` (the last one is hardcoded — easy to forget). 2. **Write release notes to `releases/.html`** (HTML body, no wrapper tags). `update-appcast.py` reads this file and embeds it as the `` block in the appcast entry. Releases without a notes file print a warning and ship without a description — never acceptable for a published release. 3. Run `just build` to verify the release version still builds cleanly. -4. Run `just release` to build, sign, notarize, zip, produce the SHA-256, and prepend the entry to `docs/appcast.xml`. This step only touches the local repo — no GitHub or tap changes yet. +4. Run `just release` to build, sign, notarize, zip, verify the extracted archive with `codesign`, `stapler validate`, and `spctl -av`, produce the SHA-256, and prepend the entry to `docs/appcast.xml`. This step only touches the local repo — no GitHub or tap changes yet. 5. Commit the version bumps + `releases/.html` + `docs/appcast.xml` change as `release: (build N)`. 6. Create and push the `v` git tag from the release commit. `just shell::publish` uses `gh release create --verify-tag` and will abort if the tag is missing on the remote. 7. Run `just shell::publish` to create the public GitHub release, upload the zip, verify that the Sparkle asset URL is publicly reachable, and rewrite `../tap/Casks/jayjay.rb` with the new `version "X.Y.Z,build"` and `sha256`. The release notes come from `releases/.html`. diff --git a/scripts/package-release-zip.sh b/scripts/package-release-zip.sh new file mode 100755 index 0000000..4daa708 --- /dev/null +++ b/scripts/package-release-zip.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: package-release-zip.sh " >&2 + exit 2 +fi + +app_path="$1" +zip_path="$2" + +if [[ ! -d "$app_path" ]]; then + echo "Error: app bundle not found: $app_path" >&2 + exit 1 +fi + +appledouble_file="$(find "$app_path" -name '._*' -print -quit)" +if [[ -n "$appledouble_file" ]]; then + echo "Error: refusing to package AppleDouble file inside app bundle: $appledouble_file" >&2 + exit 1 +fi + +macosx_dir="$(find "$app_path" -name '__MACOSX' -type d -print -quit)" +if [[ -n "$macosx_dir" ]]; then + echo "Error: refusing to package metadata directory inside app bundle: $macosx_dir" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$zip_path")" +rm -f "$zip_path" + +# --norsrc is required for signed app bundles: default ditto zip mode stores +# resource-fork metadata as ._* entries, which can break nested framework seals +# after extraction. +COPYFILE_DISABLE=1 ditto -c -k --norsrc --keepParent "$app_path" "$zip_path" + +zip_listing="$(zipinfo -1 "$zip_path")" +metadata_entry="$( + printf '%s\n' "$zip_listing" | grep -E '(^|/)\._|(^|/)__MACOSX(/|$)' | head -n 1 || true +)" +if [[ -n "$metadata_entry" ]]; then + echo "Error: archive contains AppleDouble metadata entry: $metadata_entry" >&2 + exit 1 +fi + +echo "Created $zip_path ($(du -sh "$zip_path" | cut -f1))" +shasum -a 256 "$zip_path" diff --git a/scripts/verify-release-archive.sh b/scripts/verify-release-archive.sh new file mode 100755 index 0000000..60d7420 --- /dev/null +++ b/scripts/verify-release-archive.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: verify-release-archive.sh [signed|notarized]" >&2 + exit 2 +fi + +zip_path="$1" +mode="${2:-signed}" + +case "$mode" in + signed | notarized) ;; + *) + echo "Error: mode must be 'signed' or 'notarized', got '$mode'" >&2 + exit 2 + ;; +esac + +if [[ ! -f "$zip_path" ]]; then + echo "Error: archive not found: $zip_path" >&2 + exit 1 +fi + +zip_listing="$(zipinfo -1 "$zip_path")" +metadata_entry="$( + printf '%s\n' "$zip_listing" | grep -E '(^|/)\._|(^|/)__MACOSX(/|$)' | head -n 1 || true +)" +if [[ -n "$metadata_entry" ]]; then + echo "Error: archive contains AppleDouble metadata entry: $metadata_entry" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +ditto -x -k "$zip_path" "$tmp_dir" + +apps=() +while IFS= read -r app; do + apps+=("$app") +done < <(find "$tmp_dir" -maxdepth 1 -type d -name '*.app' -print) + +if [[ "${#apps[@]}" -ne 1 ]]; then + echo "Error: expected one top-level .app in archive, found ${#apps[@]}" >&2 + exit 1 +fi + +app_path="${apps[0]}" + +appledouble_file="$(find "$app_path" -name '._*' -print -quit)" +if [[ -n "$appledouble_file" ]]; then + echo "Error: extracted app contains AppleDouble file: $appledouble_file" >&2 + exit 1 +fi + +macosx_dir="$(find "$app_path" -name '__MACOSX' -type d -print -quit)" +if [[ -n "$macosx_dir" ]]; then + echo "Error: extracted app contains metadata directory: $macosx_dir" >&2 + exit 1 +fi + +codesign --verify --deep --strict --verbose=2 "$app_path" + +if [[ "$mode" == "notarized" ]]; then + xcrun stapler validate "$app_path" + spctl -av "$app_path" +fi + +echo "Verified $zip_path ($mode)" diff --git a/shell/release.just b/shell/release.just index 1553f74..22222d7 100644 --- a/shell/release.just +++ b/shell/release.just @@ -34,19 +34,18 @@ release: ffi-release codesign --verify --deep --strict --verbose=2 "$app_path" # 3. Notarize mkdir -p "{{release_dir}}" - rm -f "{{release_dir}}/{{app_name}}-{{version}}.zip" - ditto -c -k --keepParent "{{derived_data}}/Build/Products/Release/{{app_name}}.app" \ + bash "{{root}}/scripts/package-release-zip.sh" "$app_path" \ "{{release_dir}}/{{app_name}}-{{version}}.zip" echo "Submitting for notarization with profile ${notary_profile}..." xcrun notarytool submit "{{release_dir}}/{{app_name}}-{{version}}.zip" \ --keychain-profile "${notary_profile}" --wait - xcrun stapler staple "{{derived_data}}/Build/Products/Release/{{app_name}}.app" - xcrun stapler validate "{{derived_data}}/Build/Products/Release/{{app_name}}.app" + xcrun stapler staple "$app_path" + xcrun stapler validate "$app_path" # 4. Re-zip stapled app + sha256 - rm -f "{{release_dir}}/{{app_name}}-{{version}}.zip" - ditto -c -k --keepParent "{{derived_data}}/Build/Products/Release/{{app_name}}.app" \ + bash "{{root}}/scripts/package-release-zip.sh" "$app_path" \ "{{release_dir}}/{{app_name}}-{{version}}.zip" - echo "Created notarized {{release_dir}}/{{app_name}}-{{version}}.zip" + bash "{{root}}/scripts/verify-release-archive.sh" \ + "{{release_dir}}/{{app_name}}-{{version}}.zip" notarized shasum -a 256 "{{release_dir}}/{{app_name}}-{{version}}.zip" # 5. Generate Sparkle appcast @@ -157,7 +156,11 @@ release-dry-run: ffi-release cp -R "{{derived_data}}/Build/Products/Release/{{app_name}}.app" "{{release_dir}}/" codesign --force --sign - "{{release_dir}}/{{app_name}}.app" # 3. Create zip + sha256 (no notarization) - cd "{{release_dir}}" && ditto -c -k --keepParent "{{app_name}}.app" "{{app_name}}-{{version}}.zip" + bash "{{root}}/scripts/package-release-zip.sh" \ + "{{release_dir}}/{{app_name}}.app" \ + "{{release_dir}}/{{app_name}}-{{version}}.zip" + bash "{{root}}/scripts/verify-release-archive.sh" \ + "{{release_dir}}/{{app_name}}-{{version}}.zip" signed shasum -a 256 "{{release_dir}}/{{app_name}}-{{version}}.zip" | cut -d' ' -f1 > "{{release_dir}}/{{app_name}}-{{version}}.zip.sha256" @echo "Dry run release: {{release_dir}}/{{app_name}}-{{version}}.zip" @cat "{{release_dir}}/{{app_name}}-{{version}}.zip.sha256"