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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ DerivedData/
JayJayCore.swift
.DS_Store
shell/mac/Resources/app.icon/Assets/jaybird.svg
shell/mac/Resources/DockIcon/jayjay-dock-dark.png
shell/mac/Resources/DockIcon/jayjay-dock-light.png
shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/imgs/
shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/sty/
shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/JayJay.helpindex
Expand Down
4 changes: 2 additions & 2 deletions agents/shell-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ Update this matrix when the user guide adds a feature, a shell closes a gap, or
| Rich File Previews | Yes | Partial | Raw/processed projection modes and cache identity must match. GPUI has projection controls, banners, HTML external open, native SVG preview, and a rendered Markdown preview (native block renderer, single post-change document with scrolling — same single-view model as SwiftUI). Remaining gaps: Markdown image blocks render as placeholders, not actual images, unlike SwiftUI's web view; SwiftUI also has an inline sandboxed HTML preview toggle alongside external-open, which GPUI does not yet have. |
| Review Notes | Yes | Yes | GPUI supports add/edit/resolve/delete review notes, gutter dot markers, inline note rows, file-list badges, the noted-files filter, and a stale/orphaned banner. Inline note rendering is unified-view-only in both shells; side-by-side shows a note-count banner with "Show in Unified" in both, so that is not a GPUI gap. |
| Compare Changes | Yes | Yes | Shift-click compare, bookmark diff, reverse compare, clear compare, and interdiff loading should use the same rev semantics. |
| Edit Diffs & Split Work | Yes | Partial | GPUI covers line-granularity discard ("Abandon Selected Lines"/"Abandon Change Group") from the plain diff gutter. The full Diff Edit pane (multi-file batch hunk/line selection, Move to Working Copy / New Child / New Parallel) remains SwiftUI-only; do not let its assumptions leak into shared diff rendering. |
| Edit Diffs & Split Work | Yes | Yes | Both shells cover line-granularity discard from the normal diff gutter and a dedicated multi-file Diff Edit view with per-file cards, line/hunk/file selection, select-all, keep-only-selected Done, Move to Working Copy, New Child, and New Parallel. |
| Change Operations | Yes | Partial | GPUI covers editing descriptions (including the commit box Describe button, `jj describe` on @), committing the working copy, AI commit-message generation (same codex/claude CLI chain and prompt as SwiftUI), and file multi-select with split/commit plus the batch file actions (mark reviewed/unreviewed, restore to parent with per-parent items on merges, delete from disk, ignore & untrack — same labels, gating, and core calls as SwiftUI). GPUI's split modal also carries SwiftUI's "Parallel split" checkbox, and the file-column header's reviewed/total badge and quick-split button mirror `FileColumn.swift`. Split on non-working-copy changes, Move to Working Copy, and SwiftUI's commit-box prefill from @'s existing description remain SwiftUI-only. |
| Bookmarks, Git & Pull Requests | Yes | Partial | GPUI has bookmark manager and PR-open entry points. Bookmark sync details, provider status, and full PR workflows should be checked feature by feature. |
| Workspaces | Yes | Yes | Both shells create a workspace by name into a sibling directory, open it in its own window, show workspace context, and forget workspaces. GPUI entry points: Repository menu, status-bar workspace picker, and palette; switching lives in the status-bar picker where SwiftUI also offers palette "Switch to" entries — a presentation difference, not a workflow gap. |
| Stacked Pull Requests | Yes | Gap | SwiftUI-only until GPUI gets the stacked PR preview, branch-name editing, push, and create/update flow. |
| Stacked Pull Requests | Yes | Yes | Both shells preview the detected stack, validate edited bookmark names, and submit create/update operations with dependent bases. |
| Conflict Resolution | Yes | Yes | Conflicted changes/files, conflict diff styling, Use Ours/Theirs, resolve-in-editor, and refresh-after-resolution should stay behaviorally equivalent. |
| Inspection Tools | Yes | Yes | File Annotate, File History, and Change Evolution should use the same source revs, copy values, and compare targets. |
| Command Palette | Yes | Yes | Action names, raw jj behavior, command output handling, and searchable help topics should stay aligned even if presentation differs. |
Expand Down
6 changes: 3 additions & 3 deletions crates/jayjay-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ pub use repo::{
COMMIT_MESSAGE_PROMPT, DEFAULT_REVSET, DEFAULT_REVSET_DEPTH, Repo, ReviewNoteOutputFormat,
ReviewNotesReport, add_review_note, build_default_revset, check_gh_environment,
check_glab_environment, check_jj_environment, detect_ai_provider, find_existing_binary,
generate_commit_message_cli, home_dir, init_jj_git_repo, is_executable_file,
is_valid_bookmark_name, is_valid_workspace_name, jj_binary, login_shell, login_shell_path,
resolve_review_note, review_notes_output,
generate_branch_name_cli, generate_commit_message_cli, home_dir, init_jj_git_repo,
is_executable_file, is_valid_bookmark_name, is_valid_workspace_name, jj_binary, login_shell,
login_shell_path, resolve_review_note, review_notes_output,
};
pub use theme::{DiffThemeColors, change_id_prefix_color, diff_theme_colors};
pub use tools::{
Expand Down
4 changes: 4 additions & 0 deletions crates/jayjay-core/src/placeholder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const PLACEHOLDER_PREFIXES: &[&str] = &[
"<git submodule",
"<conflict",
"<access denied",
"<file too large",
"<image ",
"symlink -> ",
];

/// True when `text` is editable text rather than a placeholder.
Expand Down Expand Up @@ -54,7 +56,9 @@ mod tests {
"<git submodule deadbeef>",
"<conflict>",
"<access denied: permission>",
"<file too large to display (over 8388608 bytes)>",
"<image (100 bytes)>",
"symlink -> target",
] {
assert!(
!is_editable_text(sample),
Expand Down
18 changes: 17 additions & 1 deletion crates/jayjay-core/src/repo/bookmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ impl Repo {
refs.sort_by(|a, b| a.0.cmp(&b.0));
let remotes: Vec<String> = refs.iter().map(|(r, _, _)| r.clone()).collect();
let is_deleted = refs.iter().any(|(_, _, tracked)| *tracked);
let first_target = &refs[0].1;
let first_target = &refs
.iter()
.find(|(remote, _, _)| remote == "origin")
.unwrap_or(&refs[0])
.1;
let (change_id, description) = self.summary_at_target(first_target);
bookmarks.push(BookmarkInfo {
name,
Expand Down Expand Up @@ -136,6 +140,18 @@ impl Repo {
}
}

pub(super) fn remote_bookmark_change_id(&self, name: &str, remote: &str) -> Option<String> {
self.get_repo()
.view()
.all_remote_bookmarks()
.find(|(symbol, remote_ref)| {
symbol.name.as_str() == name
&& symbol.remote.as_str() == remote
&& !remote_ref.target.is_absent()
})
.map(|(_, remote_ref)| self.summary_at_target(&remote_ref.target).0.id)
}

pub fn create_bookmark(&self, name: &str, rev: &str) -> CoreResult<()> {
self.with_resolved_commit_transaction(
rev,
Expand Down
52 changes: 51 additions & 1 deletion crates/jayjay-core/src/repo/commit_ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Fix: resolve crash on empty diff view\n\
- Handle nil layout manager in side-by-side diff\n\
- Add bounds check for lane index in DAG rendering";

pub const BRANCH_NAME_PROMPT: &str = "\
Generate a concise git branch name in kebab-case (lowercase words separated by hyphens, no spaces or punctuation, at most 5 words) that summarizes this change. Output only the branch name.";

/// Try to generate a commit message using an external AI CLI (codex, then claude).
/// Returns `None` if no CLI is available or all fail.
pub fn generate_commit_message_cli(diff_summary: &str) -> Option<String> {
Expand All @@ -31,6 +34,39 @@ pub fn generate_commit_message_cli(diff_summary: &str) -> Option<String> {
None
}

/// Generate and sanitize a short branch-name slug using the configured AI CLI chain.
pub fn generate_branch_name_cli(description: &str) -> Option<String> {
let reply = generate_with_cli_chain(description, BRANCH_NAME_PROMPT)?;
branch_name_slug(&reply)
}

fn generate_with_cli_chain(input: &str, prompt: &str) -> Option<String> {
if let Some(codex) = environment::find_existing_binary("codex")
&& let Some(reply) = run_ai_cli(&codex, input, prompt, AiCliMode::Codex)
{
return Some(reply);
}
if let Some(claude) = environment::find_existing_binary("claude")
&& let Some(reply) = run_ai_cli(&claude, input, prompt, AiCliMode::Claude)
{
return Some(reply);
}
None
}

fn branch_name_slug(raw: &str) -> Option<String> {
let mut words = Vec::new();
for word in raw.split(|ch: char| !ch.is_ascii_alphanumeric()) {
if !word.is_empty() {
words.push(word.to_ascii_lowercase());
if words.len() == 5 {
break;
}
}
}
(!words.is_empty()).then(|| words.join("-"))
}

/// Returns the name of the first available AI CLI provider ("Codex" or "Claude"), or empty string.
pub fn detect_ai_provider() -> String {
if environment::find_existing_binary("codex").is_some() {
Expand All @@ -51,7 +87,7 @@ fn run_ai_cli(binary: &str, diff_summary: &str, prompt: &str, mode: AiCliMode) -
use std::io::Write;
use std::time::Duration;

let full_input = format!("{prompt}\n\nChanged files:\n\n{diff_summary}");
let full_input = format!("{prompt}\n\n{diff_summary}");

let mut cmd = environment::command(binary);
cmd.stdin(std::process::Stdio::piped())
Expand Down Expand Up @@ -104,3 +140,17 @@ fn run_ai_cli(binary: &str, diff_summary: &str, prompt: &str, mode: AiCliMode) -
.to_string();
if text.is_empty() { None } else { Some(text) }
}

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

#[test]
fn branch_name_reply_is_sanitized_and_capped() {
assert_eq!(
branch_name_slug("**Add stacked PR names, safely now**").as_deref(),
Some("add-stacked-pr-names-safely")
);
assert_eq!(branch_name_slug(" -- "), None);
}
}
9 changes: 3 additions & 6 deletions crates/jayjay-core/src/repo/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ impl Repo {

let new_matcher = FilesMatcher::new(std::iter::once(new_repo_path.as_ref()));
let new_diff = first_diff_content(&trees, &new_matcher, projection_mode)?;
let (new, new_identity) = new_diff
.map(|(_, content, identity)| (content.new, identity))
let (new, new_identity, projection) = new_diff
.map(|(_, content, identity)| (content.new, identity, content.projection))
.unwrap_or_default();

Ok(DiffHunk {
Expand All @@ -171,10 +171,7 @@ impl Repo {
new,
hunk_type: HunkType::Renamed,
review_identity: hex_sha256(format!("rename|{old_identity}|{new_identity}").as_bytes()),
projection: formats::projection_for_path(
new_repo_path.as_internal_file_string(),
projection_mode,
),
projection,
})
}

Expand Down
22 changes: 17 additions & 5 deletions crates/jayjay-core/src/repo/diff/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use pollster::FutureExt as _;

use super::entry::{
compute_review_identity, diff_hunk_type, first_diff_content, materialize_diff_content,
resolve_diff_values,
materialize_file_bytes, resolve_diff_values,
};
use super::{Repo, TreePair, formats, rename::detect_renames};
use crate::types::*;
Expand All @@ -18,10 +18,22 @@ impl Repo {

while let Some(TreeDiffEntry { path, values }) = diff_stream.next().block_on() {
let values = resolve_diff_values(&path, values)?;
let projection = formats::projection_for_path(
path.as_internal_file_string(),
DiffProjectionMode::Raw,
);
let path_str = path.as_internal_file_string();
let projection = match formats::path_projection(path_str, DiffProjectionMode::Raw) {
formats::PathProjection::None => None,
formats::PathProjection::Ready(projection) => Some(projection),
formats::PathProjection::ContentGated => {
let (old, new) = materialize_file_bytes(trees, &path, values.clone())?;
formats::projection_for_input(
formats::FormatInput {
path: path_str,
old: old.as_deref(),
new: new.as_deref(),
},
DiffProjectionMode::Raw,
)
}
};
let review_identity = compute_review_identity(&values, projection.as_ref());
files.push(DiffHunk {
path: path.as_internal_file_string().to_owned(),
Expand Down
35 changes: 30 additions & 5 deletions crates/jayjay-core/src/repo/diff/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::fmt::{Display, Write};
use futures::StreamExt as _;
use jayjay_primitives::hex_sha256;
use jj_lib::backend::TreeValue;
use jj_lib::conflicts::materialize_tree_value;
use jj_lib::conflicts::{MaterializedTreeValue, materialize_tree_value};
use jj_lib::matchers::Matcher;
use jj_lib::merge::{Diff, MergedTreeValue};
use jj_lib::merged_tree::TreeDiffEntry;
Expand Down Expand Up @@ -84,13 +84,11 @@ where
})
}

pub(super) fn materialize_diff_content(
fn materialize_sides(
trees: &TreePair,
path: &RepoPath,
values: Diff<MergedTreeValue>,
projection_mode: DiffProjectionMode,
) -> CoreResult<MaterializedDiffContent> {
let hunk_type = diff_hunk_type(&values);
) -> CoreResult<(MaterializedTreeValue, MaterializedTreeValue)> {
let old_value = materialize_tree_value(
trees.repo.store(),
path,
Expand All @@ -107,6 +105,17 @@ pub(super) fn materialize_diff_content(
&format!("materialize new {}", path.as_internal_file_string()),
new_value,
)?;
Ok((old_value, new_value))
}

pub(super) fn materialize_diff_content(
trees: &TreePair,
path: &RepoPath,
values: Diff<MergedTreeValue>,
projection_mode: DiffProjectionMode,
) -> CoreResult<MaterializedDiffContent> {
let hunk_type = diff_hunk_type(&values);
let (old_value, new_value) = materialize_sides(trees, path, values)?;

if is_image_path(path.as_internal_file_string()) {
let old_result = extract_image_preview(path, old_value)?;
Expand Down Expand Up @@ -217,6 +226,22 @@ fn image_side_preview(result: ImagePreviewResult) -> Option<DiffPreview> {
}
}

pub(super) type SideBytes = (Option<Vec<u8>>, Option<Vec<u8>>);

pub(super) fn materialize_file_bytes(
trees: &TreePair,
path: &RepoPath,
values: Diff<MergedTreeValue>,
) -> CoreResult<SideBytes> {
let (old_value, new_value) = materialize_sides(trees, path, values)?;
let old = materialized_to_content(path, old_value)?;
let new = materialized_to_content(path, new_value)?;
Ok((
old.file_bytes().map(<[u8]>::to_vec),
new.file_bytes().map(<[u8]>::to_vec),
))
}

pub(super) fn first_diff_content(
trees: &TreePair,
matcher: &dyn Matcher,
Expand Down
6 changes: 4 additions & 2 deletions crates/jayjay-core/src/repo/diff/formats/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ pub(super) struct ProjectionPair {
pub(super) projection: DiffProjection,
}

pub(super) fn projection_for_path(path: &str, mode: DiffProjectionMode) -> Option<DiffProjection> {
registry::projection_for_path(path, mode)
pub(super) use registry::PathProjection;

pub(super) fn path_projection(path: &str, mode: DiffProjectionMode) -> PathProjection {
registry::path_projection(path, mode)
}

pub(super) fn projection_for_input(
Expand Down
4 changes: 4 additions & 0 deletions crates/jayjay-core/src/repo/diff/formats/plist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ impl DiffFormatPlugin for PlistPlugin {
.any(is_binary_plist)
}

fn content_gated(&self) -> bool {
true
}

fn virtual_path(&self, path: &str) -> String {
format!("{path}.xml")
}
Expand Down
14 changes: 12 additions & 2 deletions crates/jayjay-core/src/repo/diff/formats/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,18 @@ fn plugin_for_input(input: FormatInput<'_>) -> Option<&'static dyn DiffFormatPlu
.find(|plugin| plugin.matches_input(input))
}

pub(super) fn projection_for_path(path: &str, mode: DiffProjectionMode) -> Option<DiffProjection> {
plugin_for_path(path).map(|plugin| plugin.projection(path, mode))
pub(in crate::repo::diff) enum PathProjection {
None,
Ready(DiffProjection),
ContentGated,
}

pub(super) fn path_projection(path: &str, mode: DiffProjectionMode) -> PathProjection {
match plugin_for_path(path) {
None => PathProjection::None,
Some(plugin) if plugin.content_gated() => PathProjection::ContentGated,
Some(plugin) => PathProjection::Ready(plugin.projection(path, mode)),
}
}

pub(super) fn projection_for_input(
Expand Down
4 changes: 4 additions & 0 deletions crates/jayjay-core/src/repo/diff/formats/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ pub(super) trait DiffFormatPlugin: Sync {
fn matches_input(&self, input: FormatInput<'_>) -> bool {
self.matches_path(input.path)
}
/// True when `matches_input` needs file bytes to decide, so path-only callers must materialize content before assigning a projection.
fn content_gated(&self) -> bool {
false
}
fn virtual_path(&self, path: &str) -> String;
fn project(&self, input: FormatInput<'_>) -> CoreResult<ProjectionPair>;

Expand Down
4 changes: 4 additions & 0 deletions crates/jayjay-core/src/repo/diffedit/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ impl Repo {
) -> CoreResult<()> {
let repo = self.get_repo();
let commit = self.resolve_commit(&repo, rev)?;
self.ensure_commit_mutable(&repo, &commit, rev)?;
let parent_tree = self.load_parent_tree(&repo, &commit, "load parent tree")?;
self.ensure_selections_are_current(&repo, &commit.tree(), &parent_tree, selections)?;

Expand Down Expand Up @@ -86,6 +87,7 @@ impl Repo {
) -> CoreResult<()> {
let repo = self.get_repo();
let source = self.resolve_commit(&repo, rev)?;
self.ensure_commit_mutable(&repo, &source, rev)?;
let destination = self.resolve_commit(&repo, "@")?;
if source.id() == destination.id() {
return Err(CoreError::Internal {
Expand Down Expand Up @@ -132,6 +134,7 @@ impl Repo {
"extract selected changes as child",
true,
|repo, commit, repo_mut| {
self.ensure_commit_mutable(repo, commit, rev)?;
let parent_tree = self.load_parent_tree(repo, commit, "load parent tree")?;
self.ensure_selections_are_current(repo, &commit.tree(), &parent_tree, selections)?;
let source_selection = self.build_commit_selection(
Expand Down Expand Up @@ -183,6 +186,7 @@ impl Repo {
"extract selected changes as parallel",
true,
|repo, commit, repo_mut| {
self.ensure_commit_mutable(repo, commit, rev)?;
let parent_tree = self.load_parent_tree(repo, commit, "load parent tree")?;
self.ensure_selections_are_current(repo, &commit.tree(), &parent_tree, selections)?;
let source_selection = self.build_commit_selection(
Expand Down
2 changes: 1 addition & 1 deletion crates/jayjay-core/src/repo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mod workspace;

pub use commit_ai::COMMIT_MESSAGE_PROMPT;
pub use commit_ai::detect_ai_provider;
pub use commit_ai::generate_commit_message_cli;
pub use commit_ai::{generate_branch_name_cli, generate_commit_message_cli};
pub use environment::check_gh_environment;
pub use environment::check_glab_environment;
pub use environment::check_jj_environment;
Expand Down
Loading
Loading