From 4f665d272c21569cec0ae510e7ba0f516ae3efd4 Mon Sep 17 00:00:00 2001 From: hewigovens <360470+hewigovens@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:16:28 +0900 Subject: [PATCH] evolog: change evolution viewer + migrate annotate to jj_lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Right-click any change → Show evolution… opens a list of every prior rewrite (snapshot/describe/rebase/squash/split/new) with operation labels and timestamps. - Click an entry to interdiff that version against the current head; click a file for the side-by-side diff. Right-click a row to copy the commit-id or a ready-to-paste 'jj restore --from X --into @' command. - Hidden on immutable changes since their evolog is a single entry. - DetailPaneMode enum collapses the previous five mutually-exclusive @State vars (annotateLines/path, fileHistory/path, isDiffEditMode) into one type-safe state. - Evolog and annotate now run in-process via jj_lib::evolution and jj_lib::annotate (no jj CLI shell-out, no template parsing). - file_history switches to a typed revset built from RevsetExpression::filter + FilesetExpression::file_path; new log_typed shares iteration logic with log via collect_changes helper. - Command palette accepts 'jj ' as an alias for '!' so 'jj st' works. - Annotate / FileHistory / Evolog headers all use a system Done button with esc keyboard shortcut for a consistent close affordance; evolog list switches from .sidebar to .plain to fix light-mode background. --- README.md | 6 +- Roadmap.md | 20 +- crates/jayjay-core/src/repo/annotate.rs | 148 +++++++++---- crates/jayjay-core/src/repo/evolog.rs | 49 +++++ crates/jayjay-core/src/repo/log.rs | 68 +++--- crates/jayjay-core/src/repo/mod.rs | 1 + crates/jayjay-core/src/types/change.rs | 13 ++ crates/jayjay-uniffi/src/repo.rs | 4 + crates/jayjay-uniffi/src/types.rs | 15 +- .../Sources/JayJay/Detail/AnnotateView.swift | 13 +- .../JayJay/Detail/DetailDescription.swift | 2 +- .../Sources/JayJay/Detail/DetailHeader.swift | 6 +- .../JayJay/Detail/DetailPaneMode.swift | 19 ++ .../Sources/JayJay/Detail/DetailView.swift | 44 ++-- .../Sources/JayJay/Detail/EvologDisplay.swift | 74 +++++++ .../Sources/JayJay/Detail/EvologView.swift | 202 ++++++++++++++++++ .../JayJay/Detail/EvologViewModel.swift | 86 ++++++++ .../JayJay/Detail/FileColumn+Actions.swift | 13 +- .../JayJay/Detail/FileHistoryView.swift | 11 +- shell/mac/Sources/JayJay/Repo/DAGView.swift | 5 + .../mac/Sources/JayJay/Repo/RepoWindow.swift | 6 +- .../Actions/RepoViewModel+Evolog.swift | 29 +++ .../Core/RepoViewModel+Selection.swift | 4 + .../Repo/ViewModel/Core/RepoViewModel.swift | 2 + .../Sources/JayJay/Shared/ChangeActions.swift | 1 + .../JayJay/Shared/CommandPalette.swift | 13 +- 26 files changed, 723 insertions(+), 131 deletions(-) create mode 100644 crates/jayjay-core/src/repo/evolog.rs create mode 100644 shell/mac/Sources/JayJay/Detail/DetailPaneMode.swift create mode 100644 shell/mac/Sources/JayJay/Detail/EvologDisplay.swift create mode 100644 shell/mac/Sources/JayJay/Detail/EvologView.swift create mode 100644 shell/mac/Sources/JayJay/Detail/EvologViewModel.swift create mode 100644 shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift diff --git a/README.md b/README.md index c1299c2..a3623c6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Browse your DAG, review side-by-side diffs, resolve conflicts, and run every jj - DAG visualization with lane-based fork/merge rendering - Bookmark and conflict indicators on every node - Revset filtering with preset chips (All, Mine, Bookmarks, Trunk, Conflicts, Heads) +- Change evolution viewer (`jj evolog`) — see every prior version of a rewritten change with interdiff against current; right-click to copy commit-id or `jj restore` command for recovery - Load more: incrementally load older history - Auto-refresh via file system watcher @@ -45,9 +46,10 @@ Browse your DAG, review side-by-side diffs, resolve conflicts, and run every jj - Absorb hunks into ancestors, back out (revert) changes - Git push/fetch with auto-track - Bookmark Manager (⌘⇧B) with stats, filter, clean up stale branches, resolve conflicts +- Pull Request on GitHub from bookmark right-click (DAG row + Bookmark Manager) — opens the existing PR if one exists, else GitHub's compose URL - Divergent commit detection and resolution - Undo via operation log -- Command palette (⌘⇧P) with ~35 commands and jj CLI integration +- Command palette (⌘⇧P) with ~35 commands; type `jj ` (or `! `) for inline raw jj CLI output **AI Commit Messages** - Codex CLI, Claude CLI, Apple Intelligence fallback chain @@ -160,7 +162,7 @@ Yes. Toggle between unified and side-by-side diff modes with one click. Both use Yes — this is JayJay's **diff edit mode**. Select files, hunks, or line ranges across a change and split them out into a new child or parallel change (`jj diffedit` workflow). You can also right-click → Abandon Selected Lines on the working copy to drop individual edits. **How is JayJay different from using jj on the command line?** -JayJay doesn't replace the jj CLI — it complements it. The DAG graph, visual diff review, diff-edit mode, and bookmark manager are significantly easier in a GUI than in a terminal. For anything JayJay doesn't expose, the command palette (`⌘⇧P`) has an `!` prefix that drops you into a raw `jj` CLI prompt in-window. +JayJay doesn't replace the jj CLI — it complements it. The DAG graph, visual diff review, diff-edit mode, evolution viewer, and bookmark manager are significantly easier in a GUI than in a terminal. For anything JayJay doesn't expose, the command palette (`⌘⇧P`) accepts a `jj ` (or `!`) prefix that drops you into a raw `jj` CLI prompt in-window. **Is JayJay open source?** JayJay is **free and source-available**, not OSI open source. The macOS app is [BSL 1.1](LICENSE) — free to use, fork, modify, and redistribute; the only restriction is paid app-store distribution without permission. It converts to Apache-2.0 on 2030-03-23. The Rust crates (`jayjay-core`, `jayjay-uniffi`, `jj-diff`) are Apache-2.0 today. diff --git a/Roadmap.md b/Roadmap.md index 2a9c930..74614f4 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -4,8 +4,6 @@ JayJay now covers most common jj history, diff, bookmark, conflict, and Git flow ## Near-term -- [ ] Change evolution history (`jj evolog`) - Goal: show prior versions of a rewritten change with diffs — jj's killer feature, and one of the clearest places JayJay can beat git-native GUIs - [ ] 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 @@ -13,9 +11,9 @@ JayJay now covers most common jj history, diff, bookmark, conflict, and Git flow - [ ] 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 ...` -- [ ] GitHub integration via `gh` CLI - Current baseline: PR link + status checks already appear in the status bar. Next: create-PR shortcut from the bookmark menu. Keep the footprint small — we drive `gh`, we don't reimplement it + 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 @@ -45,16 +43,21 @@ JayJay now covers most common jj history, diff, bookmark, conflict, and Git flow - [x] File annotate / blame view (`jj file annotate`) — [#3](https://github.com/hewigovens/jayjay/issues/3) - [x] Graph revset filtering presets — [#5](https://github.com/hewigovens/jayjay/issues/5) - [x] Change-wide diff edit mode (`jj diffedit`) — [#6](https://github.com/hewigovens/jayjay/issues/6) +- [x] Pull Request creation from bookmark right-click (DAG row + Bookmark Manager) — [#24](https://github.com/hewigovens/jayjay/pull/24) +- [x] Change evolution viewer (`jj evolog`) with interdiff against current + Copy `jj restore` command - [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 +- 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) - Interdiff: compare any two revisions via TreePair helpers - Diff edit engine: apply selected files/hunks/line ranges to child, parallel, working-copy, or remove-from-source destinations - Revset + fileset alias resolution from jj config - Bookmarks: list, create, move, delete, rename, track - Git: push (with auto-track), fetch, remote URL -- GitHub: `gh pr view` parsing for PR link + checks +- GitHub: `gh pr view` parsing for PR link + checks; `gh_pr_open_url` resolves existing PR or builds compose URL with safe userinfo-stripping `github_slug` parser + URL-encoded bookmark - Working copy: snapshot, refresh, file restore, ignore & untrack - Rename detection, conflict/empty status, file tree building - Diff engine: LCS line diff, jj-lib word-level, context collapsing, ignore whitespace @@ -86,7 +89,10 @@ JayJay now covers most common jj history, diff, bookmark, conflict, and Git flow - Onboarding wizard with jj check + GitHub Desktop warning - jj git init button for non-jj folders - Undo via jj op log (⌘⇧U) -- Command palette (⌘⇧P): search commands, `!` prefix for jj CLI with inline output +- Command palette (⌘⇧P): search commands, `jj `/`!` prefix for jj CLI with inline output +- Pull Request on GitHub right-click action on bookmarks (DAG row + Bookmark Manager) — opens existing PR if one exists, else GitHub compose URL +- Change evolution viewer: list of past commit_ids per change with operation labels (snapshot/describe/rebase/squash/split), interdiff against current head, right-click to copy commit-id or `jj restore` command +- DetailPaneMode enum: collapses 5 mutually-exclusive `@State` vars (annotate / file history / diff edit / files) into one type-safe state - Status bar PR link + checks for the selected bookmark via `gh` - ⌘F find in diff view (native macOS find bar) - Move to Working Copy (squash files from any change into @) diff --git a/crates/jayjay-core/src/repo/annotate.rs b/crates/jayjay-core/src/repo/annotate.rs index a621e1c..44950af 100644 --- a/crates/jayjay-core/src/repo/annotate.rs +++ b/crates/jayjay-core/src/repo/annotate.rs @@ -1,59 +1,127 @@ +use std::collections::HashMap; + +use chrono::DateTime; +use chrono::Local; +use chrono::Utc; +use jj_lib::annotate::FileAnnotator; +use jj_lib::backend::CommitId; +use jj_lib::commit::Commit as JjCommit; +use jj_lib::fileset::FilesetExpression; +use jj_lib::hex_util::encode_reverse_hex; +use jj_lib::object_id::ObjectId; +use jj_lib::revset::RevsetExpression; +use jj_lib::revset::RevsetFilterPredicate; +use jj_lib::revset::SymbolResolver; +use jj_lib::revset::SymbolResolverExtension; +use pollster::FutureExt as _; + use super::Repo; use crate::types::*; impl Repo { /// Annotate a file: shows which revision last modified each line. - /// Parses `jj file annotate -r ` output. pub fn annotate_file(&self, rev: &str, path: &str) -> CoreResult> { - let output = self.run_jj(&["file", "annotate", "-r", rev, path])?; - let mut lines = Vec::new(); + let repo = self.get_repo(); + let starting = self.resolve_commit(&repo, rev)?; + let repo_path = self.parse_repo_path(path)?; + + let mut annotator = FileAnnotator::from_commit(&starting, repo_path.as_ref()) + .block_on() + .map_err(|e| CoreError::Internal { + message: format!("init annotate: {e}"), + })?; + + let user_domain = RevsetExpression::all(); + let empty_extensions: &[&Box] = &[]; + let resolver = SymbolResolver::new(repo.as_ref(), empty_extensions); + let domain = user_domain + .resolve_user_expression(repo.as_ref(), &resolver) + .map_err(|e| CoreError::Internal { + message: format!("resolve annotate domain: {e}"), + })?; - for line in output.lines() { - // Format: "changeId author date lineNo: text" - // Example: "mmxlompm 360470+h 2026-03-21 10:31:24 1: # JayJay" - let parts: Vec<&str> = line.splitn(2, ": ").collect(); - if parts.len() < 2 { - continue; - } - - let meta = parts[0]; - let text = parts[1].to_owned(); - - // Parse metadata: "changeId author date time lineNo" - let meta_parts: Vec<&str> = meta.split_whitespace().collect(); - if meta_parts.len() < 4 { - continue; - } - - let change_id = meta_parts[0].to_owned(); - let author = meta_parts[1].to_owned(); - let timestamp = format!("{} {}", meta_parts[2], meta_parts[3]); - - // Line number is the last meta part (may have leading spaces) - let line_number = meta_parts - .last() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); + annotator + .compute(repo.as_ref(), &domain) + .block_on() + .map_err(|e| CoreError::Internal { + message: format!("compute annotate: {e}"), + })?; + let annotation = annotator.to_annotation(); + let mut cache: HashMap = HashMap::new(); + let mut lines = Vec::new(); + + for (line_idx, (commit_id_result, raw_line)) in annotation.lines().enumerate() { + let commit_id = match commit_id_result { + Ok(id) | Err(id) => id, + }; + let meta = match cache.get(commit_id) { + Some(m) => m.clone(), + None => { + let m = AnnotationMeta::load(repo.as_ref(), commit_id); + cache.insert(commit_id.clone(), m.clone()); + m + } + }; + let trimmed = raw_line.strip_suffix(b"\n").unwrap_or(raw_line); + let text = String::from_utf8_lossy(trimmed).into_owned(); lines.push(AnnotationLine { - change_id, - author, - timestamp, - line_number, + change_id: meta.change_id, + author: meta.author, + timestamp: meta.timestamp, + line_number: (line_idx + 1) as u32, text, }); } - Ok(lines) } /// File history: list revisions that modified a given file path. pub fn file_history(&self, path: &str) -> CoreResult> { - let revset = format!( - "files(\"{}\")", - path.replace('\\', "\\\\").replace('"', "\\\"") - ); - let log = self.log(&revset)?; - Ok(log) + let repo_path = self.parse_repo_path(path)?; + let expression = RevsetExpression::filter(RevsetFilterPredicate::File( + FilesetExpression::file_path(repo_path), + )); + self.log_typed(expression) } } + +#[derive(Clone)] +struct AnnotationMeta { + change_id: String, + author: String, + timestamp: String, +} + +impl AnnotationMeta { + fn load(repo: &dyn jj_lib::repo::Repo, commit_id: &CommitId) -> Self { + let Ok(commit) = repo.store().get_commit(commit_id) else { + return Self::placeholder(commit_id); + }; + let change_id = encode_reverse_hex(commit.change_id().as_bytes()) + .chars() + .take(8) + .collect(); + Self { + change_id, + author: commit.author().email.clone(), + timestamp: format_timestamp(&commit), + } + } + + fn placeholder(commit_id: &CommitId) -> Self { + Self { + change_id: commit_id.hex().chars().take(8).collect(), + author: String::new(), + timestamp: String::new(), + } + } +} + +fn format_timestamp(commit: &JjCommit) -> String { + let millis = commit.author().timestamp.timestamp.0; + let Some(utc) = DateTime::::from_timestamp_millis(millis) else { + return String::new(); + }; + utc.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string() +} diff --git a/crates/jayjay-core/src/repo/evolog.rs b/crates/jayjay-core/src/repo/evolog.rs new file mode 100644 index 0000000..e8af59a --- /dev/null +++ b/crates/jayjay-core/src/repo/evolog.rs @@ -0,0 +1,49 @@ +use jj_lib::evolution::CommitEvolutionEntry; +use jj_lib::evolution::walk_predecessors; +use jj_lib::hex_util::encode_reverse_hex; +use jj_lib::object_id::ObjectId; + +use super::Repo; +use crate::types::*; + +impl Repo { + /// Evolution history of a single change. Most recent rewrite first. + pub fn evolog(&self, rev: &str) -> CoreResult> { + let repo = self.get_repo(); + let head = self.resolve_commit(&repo, rev)?; + let mut entries = Vec::new(); + for result in walk_predecessors(repo.as_ref(), &[head.id().clone()]) { + let entry = result.map_err(|e| CoreError::Internal { + message: format!("walk evolog: {e}"), + })?; + entries.push(to_dto(&entry)); + } + Ok(entries) + } +} + +fn to_dto(entry: &CommitEvolutionEntry) -> EvologEntry { + let commit = &entry.commit; + let change_id = encode_reverse_hex(commit.change_id().as_bytes()); + let commit_id = commit.id().hex(); + let (timestamp_millis, operation) = match &entry.operation { + Some(op) => { + let meta = op.metadata(); + (meta.time.start.timestamp.0, meta.description.clone()) + } + None => (commit.author().timestamp.timestamp.0, "rewrite".to_owned()), + }; + let description = commit + .description() + .lines() + .next() + .unwrap_or("") + .to_owned(); + EvologEntry { + change_id, + commit_id, + timestamp_millis, + operation, + description, + } +} diff --git a/crates/jayjay-core/src/repo/log.rs b/crates/jayjay-core/src/repo/log.rs index f59a8ce..fc586df 100644 --- a/crates/jayjay-core/src/repo/log.rs +++ b/crates/jayjay-core/src/repo/log.rs @@ -1,9 +1,11 @@ use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use jj_lib::git::REMOTE_NAME_FOR_LOCAL_GIT_REPO; use jj_lib::object_id::ObjectId; +use jj_lib::repo::ReadonlyRepo; use jj_lib::repo::Repo as _; -use jj_lib::revset::{self, RevsetDiagnostics, RevsetParseContext, SymbolResolver}; +use jj_lib::revset::{self, RevsetDiagnostics, RevsetParseContext, SymbolResolver, UserRevsetExpression}; use jj_lib::time_util::DatePatternContext; use super::Repo; @@ -12,12 +14,25 @@ use crate::types::*; impl Repo { pub fn log(&self, revset_str: &str) -> CoreResult> { let repo = self.get_repo(); - let immutable_ids = self.immutable_commit_ids(&repo); - let revset_result = self.evaluate_revset(&repo, revset_str)?; + let revset = self.evaluate_revset(&repo, revset_str)?; + self.collect_changes(&repo, revset) + } + + /// Same as `log`, but takes a pre-built typed revset expression (avoids string formatting). + pub fn log_typed(&self, expression: Arc) -> CoreResult> { + let repo = self.get_repo(); + let revset = self.evaluate_typed_revset(&repo, expression)?; + self.collect_changes(&repo, revset) + } - // First pass: collect changes without divergent info + fn collect_changes<'a>( + &self, + repo: &Arc, + revset: Box, + ) -> CoreResult> { + let immutable_ids = self.immutable_commit_ids(repo); let mut changes = Vec::new(); - for result in revset_result.iter() { + for result in revset.iter() { let commit_id = result.map_err(|e| CoreError::Internal { message: format!("revset iter: {e}"), })?; @@ -27,16 +42,15 @@ impl Repo { .map_err(|e| CoreError::Internal { message: format!("get commit: {e}"), })?; - if self.should_include_in_log(&repo, &commit) { + if self.should_include_in_log(repo, &commit) { changes.push(self.commit_to_change_info( - &repo, + repo, &commit, Some(&immutable_ids), None, )); } } - // Second pass: mark divergent (change IDs appearing more than once) Self::mark_divergent(&mut changes); Ok(changes) } @@ -127,9 +141,29 @@ impl Repo { } } + pub(crate) fn evaluate_typed_revset<'a>( + &self, + repo: &'a Arc, + expression: Arc, + ) -> CoreResult> { + #[allow(clippy::borrowed_box)] + let empty_extensions: &[&Box] = &[]; + let symbol_resolver = SymbolResolver::new(repo.as_ref(), empty_extensions); + let resolved = expression + .resolve_user_expression(repo.as_ref(), &symbol_resolver) + .map_err(|e| CoreError::Internal { + message: format!("resolve revset: {e}"), + })?; + resolved + .evaluate(repo.as_ref()) + .map_err(|e| CoreError::Internal { + message: format!("eval revset: {e}"), + }) + } + fn evaluate_revset<'a>( &self, - repo: &'a std::sync::Arc, + repo: &'a Arc, revset_str: &str, ) -> CoreResult> { let settings = repo.settings(); @@ -156,20 +190,6 @@ impl Repo { message: format!("parse revset: {e}"), } })?; - - #[allow(clippy::borrowed_box)] - let empty_extensions: &[&Box] = &[]; - let symbol_resolver = SymbolResolver::new(repo.as_ref(), empty_extensions); - let resolved = expression - .resolve_user_expression(repo.as_ref(), &symbol_resolver) - .map_err(|e| CoreError::Internal { - message: format!("resolve revset: {e}"), - })?; - - resolved - .evaluate(repo.as_ref()) - .map_err(|e| CoreError::Internal { - message: format!("eval revset: {e}"), - }) + self.evaluate_typed_revset(repo, expression) } } diff --git a/crates/jayjay-core/src/repo/mod.rs b/crates/jayjay-core/src/repo/mod.rs index e6a6b86..5970f13 100644 --- a/crates/jayjay-core/src/repo/mod.rs +++ b/crates/jayjay-core/src/repo/mod.rs @@ -7,6 +7,7 @@ mod conflicts; mod diff; mod diffedit; mod environment; +mod evolog; mod git; mod github; mod log; diff --git a/crates/jayjay-core/src/types/change.rs b/crates/jayjay-core/src/types/change.rs index 08ceebc..3a9ba60 100644 --- a/crates/jayjay-core/src/types/change.rs +++ b/crates/jayjay-core/src/types/change.rs @@ -15,6 +15,19 @@ pub struct ChangeInfo { pub is_divergent: bool, } +/// One entry in a change's evolution history (one rewrite operation). +#[derive(Debug, Clone)] +pub struct EvologEntry { + pub change_id: String, + pub commit_id: String, + /// Operation timestamp (when this rewrite happened). + pub timestamp_millis: i64, + /// Operation summary, e.g. "snapshot working copy", "describe commit X", "rebase commit X". + pub operation: String, + /// Commit description at this point in evolution (often empty for snapshots). + pub description: String, +} + /// A change with its graph edges for DAG rendering. #[derive(Debug, Clone)] pub struct GraphEntry { diff --git a/crates/jayjay-uniffi/src/repo.rs b/crates/jayjay-uniffi/src/repo.rs index 1117475..18ccd26 100644 --- a/crates/jayjay-uniffi/src/repo.rs +++ b/crates/jayjay-uniffi/src/repo.rs @@ -161,6 +161,10 @@ impl JayJayRepo { Ok(self.inner.file_history(&path)?) } + pub fn evolog(&self, rev: String) -> Result, JayJayError> { + Ok(self.inner.evolog(&rev)?) + } + pub fn resolve_list(&self, rev: String) -> Result, JayJayError> { Ok(self.inner.resolve_list(&rev)?) } diff --git a/crates/jayjay-uniffi/src/types.rs b/crates/jayjay-uniffi/src/types.rs index 6ee634d..bca41e2 100644 --- a/crates/jayjay-uniffi/src/types.rs +++ b/crates/jayjay-uniffi/src/types.rs @@ -4,14 +4,23 @@ use jayjay_core::diff::{ }; use jayjay_core::syntax::SyntaxToken; use jayjay_core::{ - AnnotationLine, BookmarkInfo, ChangeDetail, ChangeInfo, DiffEditDestination, - DiffEditFileSelection, DiffEditRange, DiffHunk, DiffPreview, DiffStats, EdgeType, - ChecksStatus, CliStatus, FetchResult, FileTreeEntry, GitSubmoduleStatus, GraphEdge, GraphEntry, + AnnotationLine, BookmarkInfo, ChangeDetail, ChangeInfo, ChecksStatus, CliStatus, + DiffEditDestination, DiffEditFileSelection, DiffEditRange, DiffHunk, DiffPreview, DiffStats, + EdgeType, EvologEntry, FetchResult, FileTreeEntry, GitSubmoduleStatus, GraphEdge, GraphEntry, HunkType, OpLogEntry, PrInfo, PrState, WorkspaceInfo, }; // --- All types use uniffi::remote — no wrapper structs or From impls --- +#[uniffi::remote(Record)] +pub struct EvologEntry { + pub change_id: String, + pub commit_id: String, + pub timestamp_millis: i64, + pub operation: String, + pub description: String, +} + #[uniffi::remote(Record)] pub struct ChangeInfo { pub change_id: String, diff --git a/shell/mac/Sources/JayJay/Detail/AnnotateView.swift b/shell/mac/Sources/JayJay/Detail/AnnotateView.swift index fc61eaa..c315f54 100644 --- a/shell/mac/Sources/JayJay/Detail/AnnotateView.swift +++ b/shell/mac/Sources/JayJay/Detail/AnnotateView.swift @@ -41,21 +41,16 @@ struct AnnotateView: View { HStack { Image(systemName: "text.line.first.and.arrowtriangle.forward") .foregroundStyle(.secondary) - Text(path) + Text("Annotate: \(path)") .jayjayFont(13, weight: .semibold, design: .monospaced) .lineLimit(1) Spacer() Text("\(lines.count) lines") .jayjayFont(11) .foregroundStyle(.secondary) - Button { - onDismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .help("Close annotate view") + Button("Done", action: onDismiss) + .keyboardShortcut(.cancelAction) + .help("Close annotate view (esc)") } .padding(.horizontal, 14) .padding(.vertical, 8) diff --git a/shell/mac/Sources/JayJay/Detail/DetailDescription.swift b/shell/mac/Sources/JayJay/Detail/DetailDescription.swift index fb3bd3b..4e1fa8f 100644 --- a/shell/mac/Sources/JayJay/Detail/DetailDescription.swift +++ b/shell/mac/Sources/JayJay/Detail/DetailDescription.swift @@ -8,7 +8,7 @@ extension ChangeDetailView { editingDescription: $editingDescription, canShowDiffEditButton: canShowDiffEditButton, onSave: { onDescribe(detail.info.changeId, $0) }, - onOpenDiffEdit: { isDiffEditMode = true } + onOpenDiffEdit: { paneMode = .diffEdit } ) .id("\(detail.info.changeId)|\(detail.info.commitId)") } diff --git a/shell/mac/Sources/JayJay/Detail/DetailHeader.swift b/shell/mac/Sources/JayJay/Detail/DetailHeader.swift index 39fbbfa..db4e84a 100644 --- a/shell/mac/Sources/JayJay/Detail/DetailHeader.swift +++ b/shell/mac/Sources/JayJay/Detail/DetailHeader.swift @@ -106,11 +106,7 @@ extension ChangeDetailView { showFileFilter = false fileFilter = "" } - annotateLines = nil - annotatePath = nil - fileHistory = nil - fileHistoryPath = nil - isDiffEditMode = false + paneMode = .files loadConflictedPaths() loadTrackedGitLfsPaths() loadDiffStats() diff --git a/shell/mac/Sources/JayJay/Detail/DetailPaneMode.swift b/shell/mac/Sources/JayJay/Detail/DetailPaneMode.swift new file mode 100644 index 0000000..2279f93 --- /dev/null +++ b/shell/mac/Sources/JayJay/Detail/DetailPaneMode.swift @@ -0,0 +1,19 @@ +import JayJayCore + +/// What the right-hand detail pane is showing. Mutually exclusive — replaces +/// the previous mix of `annotateLines`/`annotatePath`/`fileHistory`/`fileHistoryPath`/`isDiffEditMode`. +enum DetailPaneMode { + /// Default file list + diff section. + case files + case annotate(lines: [AnnotationLine], path: String) + case fileHistory(history: [ChangeInfo], path: String) + case diffEdit + + var isFiles: Bool { + if case .files = self { true } else { false } + } + + var isDiffEdit: Bool { + if case .diffEdit = self { true } else { false } + } +} diff --git a/shell/mac/Sources/JayJay/Detail/DetailView.swift b/shell/mac/Sources/JayJay/Detail/DetailView.swift index d70e4db..48297ad 100644 --- a/shell/mac/Sources/JayJay/Detail/DetailView.swift +++ b/shell/mac/Sources/JayJay/Detail/DetailView.swift @@ -13,9 +13,21 @@ struct DetailView: View { var onClearCompare: (() -> Void)? var onRevealChangeInDag: ((String) -> Void)? @Binding var activePane: ActivePane + var evologEntries: [EvologEntry]? + var evologRev: String? + var onDismissEvolog: (() -> Void)? var body: some View { - if let detail { + if let entries = evologEntries, let rev = evologRev { + EvologView( + entries: entries, + changeId: rev, + repo: repo, + diffStore: diffStore, + onDismiss: { onDismissEvolog?() } + ) + .id(rev) + } else if let detail { ChangeDetailView( repoPath: repoPath, repo: repo, detail: detail, actions: actions, onDescribe: onDescribe, @@ -64,13 +76,9 @@ struct ChangeDetailView: View { @State var showFileFilter = false @State var fileFilter = "" @State var diffStats: DiffStats? - @State var annotateLines: [AnnotationLine]? - @State var annotatePath: String? - @State var fileHistory: [ChangeInfo]? - @State var fileHistoryPath: String? + @State var paneMode: DetailPaneMode = .files @State var conflictedPaths: Set = [] @State var trackedGitLfsPaths: Set = [] - @State var isDiffEditMode = false @State var reviewedPaths: Set = [] @Environment(AppSettings.self) var appSettings @@ -113,13 +121,13 @@ struct ChangeDetailView: View { Group { if detail.diff.isEmpty || (visibleDiff.isEmpty && hiddenDiffCount > 0) { emptyState - } else if isDiffEditMode { + } else if paneMode.isDiffEdit { DiffEditView( detail: detail, repo: repo, diffStore: diffStore, actions: actions, - onDone: { isDiffEditMode = false } + onDone: { paneMode = .files } ) } else { HSplitView { @@ -223,38 +231,32 @@ struct ChangeDetailView: View { Divider() - if let lines = annotateLines, let path = annotatePath { + if case let .annotate(lines, path) = paneMode { AnnotateView( lines: lines, path: path, repo: repo, onSelectChange: { changeId in - annotatePath = nil - annotateLines = nil + paneMode = .files if let onRevealChangeInDag { onRevealChangeInDag(changeId) } else { actions?.select(changeId: changeId) } }, - onDismiss: { annotatePath = nil - annotateLines = nil - } + onDismiss: { paneMode = .files } ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let history = fileHistory, let path = fileHistoryPath { + } else if case let .fileHistory(history, path) = paneMode { FileHistoryView( history: history, path: path, onSelectChange: { changeId in - fileHistoryPath = nil - fileHistory = nil + paneMode = .files if let onRevealChangeInDag { onRevealChangeInDag(changeId) } else { actions?.select(changeId: changeId) } }, - onDismiss: { fileHistoryPath = nil - fileHistory = nil - } + onDismiss: { paneMode = .files } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let hunk = selectedHunk { @@ -270,7 +272,7 @@ struct ChangeDetailView: View { isWorkingCopy: detail.info.isWorkingCopy, diffStore: diffStore, onOpenDiffEdit: { - isDiffEditMode = true + paneMode = .diffEdit }, compareFromRev: compareFromId ) diff --git a/shell/mac/Sources/JayJay/Detail/EvologDisplay.swift b/shell/mac/Sources/JayJay/Detail/EvologDisplay.swift new file mode 100644 index 0000000..093e425 --- /dev/null +++ b/shell/mac/Sources/JayJay/Detail/EvologDisplay.swift @@ -0,0 +1,74 @@ +import JayJayCore +import SwiftUI + +/// Pure display helpers for EvologView — formatters, label/icon mappings. +enum EvologDisplay { + static func timestamp(_ millis: Int64) -> String { + let date = Date(timeIntervalSince1970: Double(millis) / 1000) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + 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 + } + + 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" + } + } + + static func hunkIcon(_ type: HunkType) -> String { + switch type { + case .added: "plus.circle" + case .removed: "minus.circle" + case .renamed: "arrow.right.circle" + case .modified: "pencil.circle" + } + } + + static func hunkColor(_ type: HunkType) -> Color { + switch type { + case .added: .green + case .removed: .red + case .renamed: .blue + case .modified: .orange + } + } +} + +extension EvologEntry { + /// Synthesize a ChangeInfo for an evolog entry whose interdiff against head is empty. + func asPlaceholderInfo() -> ChangeInfo { + ChangeInfo( + changeId: changeId, + commitId: commitId, + description: description, + author: "", + email: "", + timestampMillis: timestampMillis, + parents: [], + bookmarks: [], + isWorkingCopy: false, + hasConflict: false, + isEmpty: false, + isImmutable: false, + isDivergent: false + ) + } +} diff --git a/shell/mac/Sources/JayJay/Detail/EvologView.swift b/shell/mac/Sources/JayJay/Detail/EvologView.swift new file mode 100644 index 0000000..d1ac6dc --- /dev/null +++ b/shell/mac/Sources/JayJay/Detail/EvologView.swift @@ -0,0 +1,202 @@ +import JayJayCore +import SwiftUI + +struct EvologView: View { + @State private var viewModel: EvologViewModel + let onDismiss: () -> Void + + init( + entries: [EvologEntry], + changeId: String, + repo: JayJayRepo?, + diffStore: DiffStore, + onDismiss: @escaping () -> Void + ) { + _viewModel = State(wrappedValue: EvologViewModel( + entries: entries, changeId: changeId, repo: repo, diffStore: diffStore + )) + self.onDismiss = onDismiss + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if viewModel.entries.isEmpty { + ContentUnavailableView( + "No Evolution", + systemImage: "clock.arrow.circlepath", + description: Text("This change has no recorded rewrites.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + HSplitView { + entryList + .frame(minWidth: 240, idealWidth: 280, maxWidth: 360) + diffPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + Text("Evolution: \(String(viewModel.changeId.prefix(8)))") + .jayjayFont(13, weight: .semibold, design: .monospaced) + .lineLimit(1) + Spacer() + Text("\(viewModel.entries.count) version\(viewModel.entries.count == 1 ? "" : "s")") + .jayjayFont(11) + .foregroundStyle(.secondary) + Button("Done", action: onDismiss) + .keyboardShortcut(.cancelAction) + .help("Close evolution view (esc)") + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + private var entryList: some View { + List( + Array(viewModel.entries.enumerated()), + id: \.offset, + selection: Binding( + get: { viewModel.selectedIndex }, + set: { viewModel.selectedIndex = $0 } + ) + ) { idx, entry in + entryRow(idx: idx, entry: entry).tag(idx) + } + .listStyle(.plain) + .onChange(of: viewModel.selectedIndex) { _, newIndex in + viewModel.loadInterdiff(for: newIndex) + } + } + + private func entryRow(idx _: Int, entry: EvologEntry) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Image(systemName: EvologDisplay.operationIcon(entry.operation)) + .jayjayFont(10) + .foregroundStyle(.secondary) + Text(EvologDisplay.operationLabel(entry.operation)) + .jayjayFont(12, weight: .medium) + .lineLimit(1) + Spacer() + Text(EvologDisplay.timestamp(entry.timestampMillis)) + .jayjayFont(10) + .foregroundStyle(.tertiary) + } + HStack(spacing: 6) { + Text(String(entry.commitId.prefix(12))) + .jayjayFont(10, design: .monospaced) + .foregroundStyle(Color.accentColor.opacity(0.8)) + if !entry.description.isEmpty { + Text(entry.description) + .jayjayFont(11) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .contextMenu { + Button { + viewModel.copyCommitId(entry.commitId) + } label: { + Label("Copy Commit ID", systemImage: "doc.on.doc") + } + Button { + viewModel.copyRestoreCommand(entry.commitId) + } label: { + Label("Copy ‘jj restore’ command", systemImage: "terminal") + } + } + } + + @ViewBuilder + private var diffPane: some View { + if viewModel.selectedIndex == nil { + ContentUnavailableView( + "Select a Version", + systemImage: "arrow.left", + description: Text("Click a row on the left to see what changed since that version.") + ) + } else if viewModel.interdiffLoading { + ProgressView().controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let detail = viewModel.interdiffDetail { + if detail.diff.isEmpty { + ContentUnavailableView( + "No File Changes", + systemImage: "equal.circle", + description: Text("Description-only or identical to the current version.") + ) + } else { + interdiffContent(detail: detail) + } + } + } + + private func interdiffContent(detail: ChangeDetail) -> some View { + HSplitView { + fileList(detail: detail) + .frame(minWidth: 180, idealWidth: 220, maxWidth: 320) + if let hunk = viewModel.selectedHunk, + let from = viewModel.selectedFromCommitId, + let to = viewModel.headCommitId + { + DiffSection( + hunk: hunk, + rev: to, + repo: viewModel.repo, + actions: nil, + isWorkingCopy: false, + diffStore: viewModel.diffStore, + compareFromRev: from + ) + .id("\(from)|\(hunk.path)") + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView( + "Select a File", + systemImage: "doc", + description: Text("Pick a file from the list to see its diff.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private func fileList(detail: ChangeDetail) -> some View { + List( + detail.diff, + id: \.path, + selection: Binding( + get: { viewModel.selectedPath }, + set: { viewModel.selectedPath = $0 } + ) + ) { hunk in + HStack(spacing: 6) { + Image(systemName: EvologDisplay.hunkIcon(hunk.hunkType)) + .jayjayFont(11) + .foregroundStyle(EvologDisplay.hunkColor(hunk.hunkType)) + Text(hunk.path) + .jayjayFont(11, design: .monospaced) + .lineLimit(1) + .truncationMode(.middle) + } + .tag(hunk.path) + } + .listStyle(.plain) + .onChange(of: viewModel.selectedPath) { _, newPath in + viewModel.loadFile(path: newPath) + } + } +} diff --git a/shell/mac/Sources/JayJay/Detail/EvologViewModel.swift b/shell/mac/Sources/JayJay/Detail/EvologViewModel.swift new file mode 100644 index 0000000..58589c6 --- /dev/null +++ b/shell/mac/Sources/JayJay/Detail/EvologViewModel.swift @@ -0,0 +1,86 @@ +import AppKit +import JayJayCore +import SwiftUI + +@Observable +final class EvologViewModel { + let entries: [EvologEntry] + let changeId: String + let repo: JayJayRepo? + let diffStore: DiffStore + + var selectedIndex: Int? + var interdiffDetail: ChangeDetail? + var interdiffLoading = false + var selectedPath: String? + var selectedHunk: DiffHunk? + + /// Most recent commit_id (entries are newest-first); we diff older versions against this. + 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) { + self.entries = entries + self.changeId = changeId + self.repo = repo + self.diffStore = diffStore + } + + func loadInterdiff(for index: Int?) { + selectedHunk = nil + selectedPath = nil + interdiffDetail = nil + guard let index, entries.indices.contains(index), + let repo, let to = headCommitId + else { return } + let from = entries[index].commitId + if from == to { + interdiffDetail = ChangeDetail(info: entries[index].asPlaceholderInfo(), diff: []) + return + } + interdiffLoading = true + 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 + if let firstPath = detail?.diff.first?.path { + self.selectedPath = firstPath + // file-list `onChange` doesn't fire until that view mounts; trigger the load here. + self.loadFile(path: firstPath) + } + } + } + } + + func loadFile(path: String?) { + selectedHunk = nil + guard let path, let repo, + let from = selectedFromCommitId, let to = headCommitId + else { return } + 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 + } + } + } + + func copyCommitId(_ commitId: String) { + copyToPasteboard(commitId) + } + + func copyRestoreCommand(_ commitId: String) { + copyToPasteboard("jj restore --from \(commitId) --into @") + } + + private func copyToPasteboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} diff --git a/shell/mac/Sources/JayJay/Detail/FileColumn+Actions.swift b/shell/mac/Sources/JayJay/Detail/FileColumn+Actions.swift index 0ca3a61..7263e05 100644 --- a/shell/mac/Sources/JayJay/Detail/FileColumn+Actions.swift +++ b/shell/mac/Sources/JayJay/Detail/FileColumn+Actions.swift @@ -113,24 +113,25 @@ extension ChangeDetailView { func loadAnnotate(rev: String, path: String) { guard let repo else { return } - annotatePath = path - annotateLines = nil + paneMode = .annotate(lines: [], path: path) Task.detached { let lines = try? repo.annotateFile(rev: rev, path: path) await MainActor.run { - annotateLines = lines ?? [] + // Stale-result guard: bail if user closed the pane or moved on. + guard case let .annotate(_, currentPath) = paneMode, currentPath == path else { return } + paneMode = .annotate(lines: lines ?? [], path: path) } } } func loadFileHistory(path: String) { guard let repo else { return } - fileHistoryPath = path - fileHistory = nil + paneMode = .fileHistory(history: [], path: path) Task.detached { let history = try? repo.fileHistory(path: path) await MainActor.run { - fileHistory = history ?? [] + guard case let .fileHistory(_, currentPath) = paneMode, currentPath == path else { return } + paneMode = .fileHistory(history: history ?? [], path: path) } } } diff --git a/shell/mac/Sources/JayJay/Detail/FileHistoryView.swift b/shell/mac/Sources/JayJay/Detail/FileHistoryView.swift index 0cf0cad..dd9719d 100644 --- a/shell/mac/Sources/JayJay/Detail/FileHistoryView.swift +++ b/shell/mac/Sources/JayJay/Detail/FileHistoryView.swift @@ -67,14 +67,9 @@ struct FileHistoryView: View { Text("\(history.count) revisions") .jayjayFont(11) .foregroundStyle(.secondary) - Button { - onDismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .help("Close history view") + Button("Done", action: onDismiss) + .keyboardShortcut(.cancelAction) + .help("Close history view (esc)") } .padding(.horizontal, 14) .padding(.vertical, 8) diff --git a/shell/mac/Sources/JayJay/Repo/DAGView.swift b/shell/mac/Sources/JayJay/Repo/DAGView.swift index 5c9268a..9087ba4 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGView.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGView.swift @@ -151,6 +151,11 @@ struct DAGView: View { Button { onCreateBookmark?(rev) } label: { Label("Create bookmark here...", systemImage: "bookmark") } + if !entry.change.isImmutable { + Button { actions?.showEvolog(rev: rev) } label: { + Label("Show evolution…", systemImage: "clock.arrow.circlepath") + } + } Divider() Menu { diff --git a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift index add0c8e..f0af817 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift @@ -189,7 +189,10 @@ struct RepoContentView: View { compareFromId: viewModel.compareFromId, onClearCompare: { viewModel.clearCompare() }, onRevealChangeInDag: revealChangeInDAG, - activePane: $activePane + activePane: $activePane, + evologEntries: viewModel.evologEntries, + evologRev: viewModel.evologRev, + onDismissEvolog: { viewModel.dismissEvolog() } ) .frame(maxWidth: .infinity) } @@ -204,7 +207,6 @@ struct RepoContentView: View { dagRevealRequest = DAGRevealRequest(changeId: changeId) viewModel.select(changeId: changeId) } - } @MainActor diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift new file mode 100644 index 0000000..17535cf --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift @@ -0,0 +1,29 @@ +import Foundation +import JayJayCore + +extension RepoViewModel { + func showEvolog(rev: String) { + guard !rev.isEmpty else { return } + // Select first so the detail pane focuses the change being inspected. + if selectedChangeId != rev { + select(changeId: rev) + } + evologRev = rev + evologEntries = nil + load { repo in + try repo.evolog(rev: rev) + } onSuccess: { vm, entries in + guard vm.evologRev == rev else { return } // user moved on while loading + vm.evologEntries = entries + } onFailure: { vm, error in + guard vm.evologRev == rev else { return } // don't clobber a newer request + vm.evologRev = nil + vm.present(error: error) + } + } + + func dismissEvolog() { + evologRev = nil + evologEntries = nil + } +} diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift index 4be4679..4e36aa7 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift @@ -173,6 +173,10 @@ extension RepoViewModel { func select(changeId: String?) { compareFromId = nil selectedChangeId = changeId + if changeId != evologRev { + evologEntries = nil + evologRev = nil + } guard let changeId else { selectedChange = nil return diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift index 6f808dd..e6fa448 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift @@ -45,6 +45,8 @@ final class RepoViewModel: ChangeActions, DAGActions, BookmarkActions { var isRefreshingInFlight: Bool = false var prInfo: PrInfo? var prFetchTask: Task? + var evologEntries: [EvologEntry]? + var evologRev: String? init(path: String) throws { repoPath = path diff --git a/shell/mac/Sources/JayJay/Shared/ChangeActions.swift b/shell/mac/Sources/JayJay/Shared/ChangeActions.swift index d9bf437..32ac5d5 100644 --- a/shell/mac/Sources/JayJay/Shared/ChangeActions.swift +++ b/shell/mac/Sources/JayJay/Shared/ChangeActions.swift @@ -43,6 +43,7 @@ protocol DAGActions: AnyObject { func rebase(rev: String, dest: String) func abandon(rev: String) func compareWith(from: String, to: String) + func showEvolog(rev: String) } protocol BookmarkActions: AnyObject { diff --git a/shell/mac/Sources/JayJay/Shared/CommandPalette.swift b/shell/mac/Sources/JayJay/Shared/CommandPalette.swift index 36ecb2c..54de3c3 100644 --- a/shell/mac/Sources/JayJay/Shared/CommandPalette.swift +++ b/shell/mac/Sources/JayJay/Shared/CommandPalette.swift @@ -80,12 +80,19 @@ private struct PaletteRoot: View { @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 ", "!"] + private var isJJ: Bool { - query.hasPrefix("!") + query == "jj" || Self.jjPrefixes.contains(where: query.hasPrefix) } private var jjCmd: String { - String(query.dropFirst()).trimmingCharacters(in: .whitespaces) + 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] { @@ -101,7 +108,7 @@ private struct PaletteRoot: View { Image(systemName: isJJ ? "terminal" : "magnifyingglass") .foregroundStyle(.secondary) .frame(width: 16) - TextField("Type a command or ! for jj CLI...", text: $query) + TextField("Type a command or 'jj ' / '!' for jj CLI...", text: $query) .textFieldStyle(.plain) .font(.system(size: 14)) .accessibilityIdentifier(AID.Palette.textField)