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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <args>` (or `! <args>`) for inline raw jj CLI output

**AI Commit Messages**
- Codex CLI, Claude CLI, Apple Intelligence fallback chain
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 13 additions & 7 deletions Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@ 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
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 ...`
- [ ] 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 @)
Expand Down
148 changes: 108 additions & 40 deletions crates/jayjay-core/src/repo/annotate.rs
Original file line number Diff line number Diff line change
@@ -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 <rev> <path>` output.
pub fn annotate_file(&self, rev: &str, path: &str) -> CoreResult<Vec<AnnotationLine>> {
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<dyn SymbolResolverExtension>] = &[];
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::<u32>().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<CommitId, AnnotationMeta> = 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<Vec<ChangeInfo>> {
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::<Utc>::from_timestamp_millis(millis) else {
return String::new();
};
utc.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string()
}
49 changes: 49 additions & 0 deletions crates/jayjay-core/src/repo/evolog.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<EvologEntry>> {
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,
}
}
Loading
Loading