diff --git a/.gitignore b/.gitignore index 424c5677..e309aacd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/agents/shell-parity.md b/agents/shell-parity.md index 994bd755..e5ed0b2b 100644 --- a/agents/shell-parity.md +++ b/agents/shell-parity.md @@ -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. | diff --git a/crates/jayjay-core/src/lib.rs b/crates/jayjay-core/src/lib.rs index a931e3e0..2063e534 100644 --- a/crates/jayjay-core/src/lib.rs +++ b/crates/jayjay-core/src/lib.rs @@ -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::{ diff --git a/crates/jayjay-core/src/placeholder.rs b/crates/jayjay-core/src/placeholder.rs index 2f9128c9..fa93a993 100644 --- a/crates/jayjay-core/src/placeholder.rs +++ b/crates/jayjay-core/src/placeholder.rs @@ -12,7 +12,9 @@ const PLACEHOLDER_PREFIXES: &[&str] = &[ " ", ]; /// True when `text` is editable text rather than a placeholder. @@ -54,7 +56,9 @@ mod tests { "", "", "", + "", "", + "symlink -> target", ] { assert!( !is_editable_text(sample), diff --git a/crates/jayjay-core/src/repo/bookmarks.rs b/crates/jayjay-core/src/repo/bookmarks.rs index e8369394..502cc317 100644 --- a/crates/jayjay-core/src/repo/bookmarks.rs +++ b/crates/jayjay-core/src/repo/bookmarks.rs @@ -98,7 +98,11 @@ impl Repo { refs.sort_by(|a, b| a.0.cmp(&b.0)); let remotes: Vec = 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, @@ -136,6 +140,18 @@ impl Repo { } } + pub(super) fn remote_bookmark_change_id(&self, name: &str, remote: &str) -> Option { + 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, diff --git a/crates/jayjay-core/src/repo/commit_ai.rs b/crates/jayjay-core/src/repo/commit_ai.rs index 21daba84..7e722408 100644 --- a/crates/jayjay-core/src/repo/commit_ai.rs +++ b/crates/jayjay-core/src/repo/commit_ai.rs @@ -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 { @@ -31,6 +34,39 @@ pub fn generate_commit_message_cli(diff_summary: &str) -> Option { None } +/// Generate and sanitize a short branch-name slug using the configured AI CLI chain. +pub fn generate_branch_name_cli(description: &str) -> Option { + let reply = generate_with_cli_chain(description, BRANCH_NAME_PROMPT)?; + branch_name_slug(&reply) +} + +fn generate_with_cli_chain(input: &str, prompt: &str) -> Option { + 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 { + 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() { @@ -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()) @@ -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); + } +} diff --git a/crates/jayjay-core/src/repo/diff.rs b/crates/jayjay-core/src/repo/diff.rs index ec7000f9..17856c64 100644 --- a/crates/jayjay-core/src/repo/diff.rs +++ b/crates/jayjay-core/src/repo/diff.rs @@ -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 { @@ -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, }) } diff --git a/crates/jayjay-core/src/repo/diff/content.rs b/crates/jayjay-core/src/repo/diff/content.rs index 6525fad8..7957eda7 100644 --- a/crates/jayjay-core/src/repo/diff/content.rs +++ b/crates/jayjay-core/src/repo/diff/content.rs @@ -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::*; @@ -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(), diff --git a/crates/jayjay-core/src/repo/diff/entry.rs b/crates/jayjay-core/src/repo/diff/entry.rs index 2f4b397c..837743ef 100644 --- a/crates/jayjay-core/src/repo/diff/entry.rs +++ b/crates/jayjay-core/src/repo/diff/entry.rs @@ -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; @@ -84,13 +84,11 @@ where }) } -pub(super) fn materialize_diff_content( +fn materialize_sides( trees: &TreePair, path: &RepoPath, values: Diff, - projection_mode: DiffProjectionMode, -) -> CoreResult { - let hunk_type = diff_hunk_type(&values); +) -> CoreResult<(MaterializedTreeValue, MaterializedTreeValue)> { let old_value = materialize_tree_value( trees.repo.store(), path, @@ -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, + projection_mode: DiffProjectionMode, +) -> CoreResult { + 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)?; @@ -217,6 +226,22 @@ fn image_side_preview(result: ImagePreviewResult) -> Option { } } +pub(super) type SideBytes = (Option>, Option>); + +pub(super) fn materialize_file_bytes( + trees: &TreePair, + path: &RepoPath, + values: Diff, +) -> CoreResult { + 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, diff --git a/crates/jayjay-core/src/repo/diff/formats/mod.rs b/crates/jayjay-core/src/repo/diff/formats/mod.rs index 9294b104..5e793b02 100644 --- a/crates/jayjay-core/src/repo/diff/formats/mod.rs +++ b/crates/jayjay-core/src/repo/diff/formats/mod.rs @@ -21,8 +21,10 @@ pub(super) struct ProjectionPair { pub(super) projection: DiffProjection, } -pub(super) fn projection_for_path(path: &str, mode: DiffProjectionMode) -> Option { - 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( diff --git a/crates/jayjay-core/src/repo/diff/formats/plist.rs b/crates/jayjay-core/src/repo/diff/formats/plist.rs index 7b4c334f..c6948c46 100644 --- a/crates/jayjay-core/src/repo/diff/formats/plist.rs +++ b/crates/jayjay-core/src/repo/diff/formats/plist.rs @@ -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") } diff --git a/crates/jayjay-core/src/repo/diff/formats/registry.rs b/crates/jayjay-core/src/repo/diff/formats/registry.rs index c53f17ce..922195c9 100644 --- a/crates/jayjay-core/src/repo/diff/formats/registry.rs +++ b/crates/jayjay-core/src/repo/diff/formats/registry.rs @@ -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 { - 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( diff --git a/crates/jayjay-core/src/repo/diff/formats/types.rs b/crates/jayjay-core/src/repo/diff/formats/types.rs index 9dcd7a10..2301d617 100644 --- a/crates/jayjay-core/src/repo/diff/formats/types.rs +++ b/crates/jayjay-core/src/repo/diff/formats/types.rs @@ -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; diff --git a/crates/jayjay-core/src/repo/diffedit/operations.rs b/crates/jayjay-core/src/repo/diffedit/operations.rs index 679dbcec..a8b721d2 100644 --- a/crates/jayjay-core/src/repo/diffedit/operations.rs +++ b/crates/jayjay-core/src/repo/diffedit/operations.rs @@ -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)?; @@ -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 { @@ -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( @@ -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( diff --git a/crates/jayjay-core/src/repo/mod.rs b/crates/jayjay-core/src/repo/mod.rs index 267919f7..78ea8e08 100644 --- a/crates/jayjay-core/src/repo/mod.rs +++ b/crates/jayjay-core/src/repo/mod.rs @@ -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; diff --git a/crates/jayjay-core/src/repo/stacked_pr/github.rs b/crates/jayjay-core/src/repo/stacked_pr/github.rs index 68f94837..bbb3b1e9 100644 --- a/crates/jayjay-core/src/repo/stacked_pr/github.rs +++ b/crates/jayjay-core/src/repo/stacked_pr/github.rs @@ -26,21 +26,31 @@ pub(super) fn preflight(repo: &Repo) -> CoreResult<()> { /// Create a PR for `target` (head = its bookmark, base = the layer below), or /// retarget an existing PR's base. Best-effort: each layer's failure is captured. pub(super) fn create_or_update_pr(repo: &Repo, target: &ForgeTarget) -> SubmittedLayer { - match existing_pr(repo, &target.bookmark) { + match open_pr(repo, &target.bookmark) { Some((number, url)) => update_base(repo, target, number, url), None => create_pr(repo, target), } } -/// `gh pr view` argv with `head` after `--`, so an option-shaped bookmark name -/// can't be parsed as a flag. -fn pr_view_args(head: &str) -> [&str; 6] { - ["pr", "view", "--json", "number,url", "--", head] +/// Lists only OPEN PRs for `head`: a closed or merged PR on the same branch must not be reused (its base can't be edited), so absent an open one we create a fresh PR. `head` is an option value, so an option-shaped bookmark name can't be parsed as a flag. +fn open_pr_args(head: &str) -> [&str; 10] { + [ + "pr", + "list", + "--state", + "open", + "--limit", + "1", + "--json", + "number,url", + "--head", + head, + ] } -fn existing_pr(repo: &Repo, head: &str) -> Option<(u32, String)> { +fn open_pr(repo: &Repo, head: &str) -> Option<(u32, String)> { let out = repo - .command_output(&gh_binary(), &pr_view_args(head), "gh pr view") + .command_output(&gh_binary(), &open_pr_args(head), "gh pr list") .ok()?; if !out.status.success() { return None; @@ -50,8 +60,8 @@ fn existing_pr(repo: &Repo, head: &str) -> Option<(u32, String)> { number: u32, url: String, } - let view: View = serde_json::from_str(&Repo::stdout_text(&out)).ok()?; - Some((view.number, view.url)) + let views: Vec = serde_json::from_str(&Repo::stdout_text(&out)).ok()?; + views.into_iter().next().map(|view| (view.number, view.url)) } fn update_base(repo: &Repo, target: &ForgeTarget, number: u32, url: String) -> SubmittedLayer { @@ -134,17 +144,15 @@ fn create_pr(repo: &Repo, target: &ForgeTarget) -> SubmittedLayer { #[cfg(test)] mod tests { - use super::pr_view_args; + use super::open_pr_args; #[test] - fn pr_view_args_put_head_after_separator() { - assert_eq!( - pr_view_args("feat-x"), - ["pr", "view", "--json", "number,url", "--", "feat-x"] - ); - // An option-shaped bookmark lands after `--`, never parsed as a flag. - let args = pr_view_args("--repo=evil"); - assert_eq!(args[args.len() - 2], "--"); - assert_eq!(args[args.len() - 1], "--repo=evil"); + fn open_pr_lookup_is_scoped_to_open_state_and_head_option() { + let args = open_pr_args("feat-x"); + assert!(args.windows(2).any(|pair| pair == ["--state", "open"])); + assert_eq!(args[args.len() - 2..], ["--head", "feat-x"]); + // An option-shaped bookmark is consumed as --head's value, never parsed as a flag. + let args = open_pr_args("--repo=evil"); + assert_eq!(args[args.len() - 2..], ["--head", "--repo=evil"]); } } diff --git a/crates/jayjay-core/src/repo/stacked_pr/gitlab.rs b/crates/jayjay-core/src/repo/stacked_pr/gitlab.rs index 1353f3d4..09284a13 100644 --- a/crates/jayjay-core/src/repo/stacked_pr/gitlab.rs +++ b/crates/jayjay-core/src/repo/stacked_pr/gitlab.rs @@ -72,9 +72,11 @@ fn existing_mr(repo: &Repo, head: &str) -> Option<(u32, String)> { struct View { iid: u32, web_url: String, + state: String, } let view: View = serde_json::from_str(&Repo::stdout_text(&out)).ok()?; - Some((view.iid, view.web_url)) + // A closed or merged MR on the same branch must not be reused (its target can't be edited); create a fresh MR instead. + (view.state == "opened").then_some((view.iid, view.web_url)) } fn update_target(repo: &Repo, target: &ForgeTarget, iid: u32, url: String) -> SubmittedLayer { diff --git a/crates/jayjay-core/src/repo/stacked_pr/mod.rs b/crates/jayjay-core/src/repo/stacked_pr/mod.rs index 66f80cfe..4976c741 100644 --- a/crates/jayjay-core/src/repo/stacked_pr/mod.rs +++ b/crates/jayjay-core/src/repo/stacked_pr/mod.rs @@ -22,31 +22,11 @@ impl Repo { }); } changes.reverse(); // bottom → top + validate_stack_changes(&changes)?; let base_bookmark = self.default_pull_request_base(); let mut layers: Vec = Vec::with_capacity(changes.len()); for (i, change) in changes.iter().enumerate() { - if change.is_immutable { - return Err(CoreError::Internal { - message: "The stack contains an immutable change.".to_owned(), - }); - } - // A divergent change-id resolves to multiple commits, so move_bookmark - // would fail mid-submit after earlier bookmarks were already moved. - // Reject up front instead of carrying it through to submit. - if change.is_divergent { - return Err(CoreError::Internal { - message: "The stack contains a divergent change. Resolve the divergence (abandon the duplicate) before submitting." - .to_owned(), - }); - } - if change.parents.len() != 1 - || (i > 0 && change.parents[0] != changes[i - 1].commit_id.id) - { - return Err(CoreError::Internal { - message: "The stack must be linear (no merges).".to_owned(), - }); - } let short_len = (change.change_id.short_len as usize).min(change.change_id.id.len()); let change_id_short = change.change_id.id[..short_len].to_owned(); let existing = change.bookmarks.first().cloned(); @@ -109,6 +89,45 @@ impl Repo { }); } + // The panel is only a preview. Reload and resolve every change again before the first bookmark move so an external abandon, divergence, or reparent cannot leave a partially submitted stack. + self.reload()?; + let current_changes = self.resolve_stack_changes(&layers)?; + validate_stack_changes(¤t_changes)?; + + // An edited name may already belong to another local or remote change (most dangerously, the trunk bookmark), so reject the whole plan before forge preflight or any bookmark moves instead of silently retargeting it. + let bookmarks = self.list_bookmarks()?; + if let Some((layer, existing)) = layers.iter().find_map(|layer| { + bookmarks + .iter() + .find(|bookmark| { + bookmark.name == layer.bookmark + && !self.bookmark_targets_change(bookmark, &layer.change_id) + }) + .map(|bookmark| (layer, bookmark)) + }) { + let owner = if existing.is_conflicted { + "a conflicted change".to_owned() + } else { + let origin_owner = self + .remote_bookmark_change_id(&existing.name, "origin") + .filter(|change| !change.is_empty() && change != &layer.change_id); + let local_owner = (existing.has_local_target + && !existing.is_deleted + && existing.change_id.as_str() != layer.change_id) + .then(|| existing.change_id.id.clone()); + origin_owner.or(local_owner).map_or_else( + || "another local or origin change".to_owned(), + |change| format!("change {change}"), + ) + }; + return Err(CoreError::Internal { + message: format!( + "Bookmark \"{}\" already belongs to {owner}; choose a different bookmark for change {}.", + layer.bookmark, layer.change_id + ), + }); + } + // Dependent bases work the same on GitHub (`gh`) and GitLab (`glab`). let host = self .git_remote_url() @@ -165,4 +184,61 @@ impl Repo { Ok(StackedPrResult { layers, message }) } + + fn resolve_stack_changes(&self, layers: &[SubmitStackLayer]) -> CoreResult> { + layers + .iter() + .map(|layer| { + let mut matches = self.log(&layer.change_id)?; + if matches.len() != 1 || matches[0].change_id.id != layer.change_id { + return Err(CoreError::Internal { + message: "The stack changed since preview. Refresh it before submitting." + .to_owned(), + }); + } + Ok(matches.pop().expect("exactly one stack change")) + }) + .collect() + } + + fn bookmark_targets_change(&self, bookmark: &BookmarkInfo, change_id: &str) -> bool { + if bookmark.is_conflicted { + return false; + } + let local_active = bookmark.has_local_target && !bookmark.is_deleted; + if local_active && bookmark.change_id.as_str() != change_id { + return false; + } + let origin_change = self.remote_bookmark_change_id(&bookmark.name, "origin"); + if origin_change + .as_deref() + .is_some_and(|remote_change| remote_change != change_id) + { + return false; + } + local_active || origin_change.as_deref() == Some(change_id) + } +} + +fn validate_stack_changes(changes: &[ChangeInfo]) -> CoreResult<()> { + for (i, change) in changes.iter().enumerate() { + if change.is_immutable { + return Err(CoreError::Internal { + message: "The stack contains an immutable change.".to_owned(), + }); + } + if change.is_divergent { + return Err(CoreError::Internal { + message: "The stack contains a divergent change. Resolve the divergence (abandon the duplicate) before submitting." + .to_owned(), + }); + } + if change.parents.len() != 1 || (i > 0 && change.parents[0] != changes[i - 1].commit_id.id) + { + return Err(CoreError::Internal { + message: "The stack must be linear (no merges).".to_owned(), + }); + } + } + Ok(()) } diff --git a/crates/jayjay-core/tests/diffedit_immutable.rs b/crates/jayjay-core/tests/diffedit_immutable.rs new file mode 100644 index 00000000..c0958679 --- /dev/null +++ b/crates/jayjay-core/tests/diffedit_immutable.rs @@ -0,0 +1,38 @@ +use std::fs; + +use jayjay_core::{DiffEditDestination, Repo}; +use jj_test::{init_jj_repo, run_git, run_jj_in, whole_file_selection}; + +#[test] +fn diffedit_refuses_every_destination_for_an_immutable_source() { + for destination in [ + DiffEditDestination::RemoveFromSource, + DiffEditDestination::MoveToWorkingCopy, + DiffEditDestination::NewChild, + DiffEditDestination::NewParallel, + ] { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + fs::write(repo_path.join("notes.md"), "selected\nrest\n").expect("write source"); + run_jj_in(&repo_path, &["describe", "-m", "protected"]); + run_jj_in(&repo_path, &["new", "-m", "child"]); + let repo = Repo::open(&repo_path).expect("open repo"); + let selection = whole_file_selection(&repo, "@-", "notes.md"); + run_git(&repo_path, &["tag", "release"]); + run_jj_in(&repo_path, &["st"]); + + let err = repo + .apply_diff_selection("@-", destination, &[selection], "selected", false) + .expect_err("immutable source must not be rewritten"); + assert!( + err.to_string().contains("immutable"), + "unclear error for {destination:?}: {err}" + ); + assert_eq!( + repo.file_content("@-", "notes.md") + .expect("immutable file remains") + .trim_end(), + "selected\nrest" + ); + } +} diff --git a/crates/jayjay-core/tests/format_projections.rs b/crates/jayjay-core/tests/format_projections.rs index f72d7958..5d0f8022 100644 --- a/crates/jayjay-core/tests/format_projections.rs +++ b/crates/jayjay-core/tests/format_projections.rs @@ -90,6 +90,28 @@ fn plist_projection_reads_binary_plist_as_sorted_xml() { assert!(content.contains("features")); } +#[test] +fn summary_projects_only_binary_plists() { + let (_fixture, repo) = fixture_repo(); + + let detail = repo.show_summary("@").expect("show summary"); + let binary = detail + .diff + .iter() + .find(|hunk| hunk.path == FormatFixture::PLIST) + .expect("binary plist hunk"); + assert_eq!( + binary.projection.as_ref().expect("projected").plugin_id, + "plist" + ); + let plain = detail + .diff + .iter() + .find(|hunk| hunk.path == FormatFixture::XML_PLIST) + .expect("plain plist hunk"); + assert!(plain.projection.is_none()); +} + #[test] fn plain_xml_plist_stays_a_source_diff() { let (_fixture, repo) = fixture_repo(); diff --git a/crates/jayjay-core/tests/stacked_pr.rs b/crates/jayjay-core/tests/stacked_pr.rs index edee37c7..6248d054 100644 --- a/crates/jayjay-core/tests/stacked_pr.rs +++ b/crates/jayjay-core/tests/stacked_pr.rs @@ -1,5 +1,18 @@ -use jayjay_core::Repo; -use jj_test::{init_jj_repo, run_jj}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use jayjay_core::{Repo, SubmitStackLayer}; +use jj_test::{init_jj_repo, run_command, run_git, run_jj}; + +fn submit_layer(change_id: String, bookmark: &str, title: &str) -> SubmitStackLayer { + SubmitStackLayer { + change_id, + bookmark: bookmark.to_owned(), + title: title.to_owned(), + body: String::new(), + } +} #[test] fn detect_stack_builds_linear_layers_with_dependent_bases() { @@ -58,3 +71,236 @@ fn detect_stack_reuses_an_existing_bookmark() { assert!(stack.layers[0].bookmark_existed); assert_eq!(stack.layers[0].bookmark, "my-feature"); } + +#[test] +fn submit_stack_revalidates_order_before_moving_bookmarks() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo_str = repo_path.to_str().expect("repo path utf-8"); + + run_jj(&["-R", repo_str, "describe", "-m", "base"]); + run_jj(&["-R", repo_str, "bookmark", "create", "main", "-r", "@"]); + run_jj(&["-R", repo_str, "new", "-m", "layer one"]); + run_jj(&["-R", repo_str, "new", "-m", "layer two"]); + + let repo = Repo::open(&repo_path).expect("open repo"); + let stack = repo.detect_stack("main", "@").expect("detect stack"); + let top_change = stack.layers[1].change_id.clone(); + let submitted = stack + .layers + .iter() + .map(|layer| submit_layer(layer.change_id.clone(), &layer.bookmark, &layer.title)) + .collect(); + + run_jj(&["-R", repo_str, "rebase", "-r", &top_change, "-d", "main"]); + + let error = repo + .submit_stack(submitted) + .expect_err("reparented stack must fail"); + assert!( + error.to_string().contains("stack must be linear"), + "unexpected error: {error}" + ); + assert_eq!( + repo.list_bookmarks() + .expect("list bookmarks after failure") + .iter() + .filter(|bookmark| bookmark.has_local_target && !bookmark.is_deleted) + .map(|bookmark| bookmark.name.as_str()) + .collect::>(), + ["main"], + "submission must not move or create bookmarks" + ); +} + +#[test] +fn submit_stack_rejects_bookmark_owned_by_another_change_before_mutation() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo_str = repo_path.to_str().expect("repo path utf-8"); + + run_jj(&["-R", repo_str, "describe", "-m", "base"]); + run_jj(&["-R", repo_str, "bookmark", "create", "main", "-r", "@"]); + run_jj(&["-R", repo_str, "new", "-m", "feature"]); + + let repo = Repo::open(&repo_path).expect("open repo"); + let feature = repo.show_summary("@").expect("show feature").info; + let main_before = repo + .list_bookmarks() + .expect("list bookmarks") + .into_iter() + .find(|bookmark| bookmark.name == "main") + .expect("main bookmark") + .change_id + .id; + + let error = repo + .submit_stack(vec![submit_layer(feature.change_id.id, "main", "Feature")]) + .expect_err("bookmark collision must fail"); + + assert!( + error + .to_string() + .contains("Bookmark \"main\" already belongs to change"), + "unexpected error: {error}" + ); + let bookmarks = repo.list_bookmarks().expect("list bookmarks after failure"); + assert_eq!( + bookmarks + .iter() + .find(|bookmark| bookmark.name == "main") + .expect("main bookmark preserved") + .change_id + .id, + main_before + ); + assert_eq!( + bookmarks + .iter() + .filter(|bookmark| bookmark.has_local_target && !bookmark.is_deleted) + .count(), + 1, + "submission must not create another local bookmark" + ); +} + +#[test] +fn submit_stack_rejects_remote_only_bookmark_before_mutation() { + let (_work_dir, bob_path) = remote_bookmark_fixture(); + let repo = Repo::open(&bob_path).expect("open bob repo"); + let feature = repo.show_summary("@").expect("show bob feature").info; + + let error = repo + .submit_stack(vec![submit_layer( + feature.change_id.id, + "reserved", + "Bob feature", + )]) + .expect_err("remote-only bookmark collision must fail"); + + assert!( + error + .to_string() + .contains("Bookmark \"reserved\" already belongs to change"), + "unexpected error: {error}" + ); + let reserved = repo + .list_bookmarks() + .expect("list bookmarks after failure") + .into_iter() + .find(|bookmark| bookmark.name == "reserved") + .expect("reserved remote bookmark preserved"); + assert!( + !reserved.has_local_target, + "submission must not create a local bookmark" + ); +} + +#[test] +fn submit_stack_rejects_diverged_untracked_origin_bookmark() { + let (_work_dir, bob_path) = remote_bookmark_fixture(); + let bob_str = bob_path.to_str().expect("bob path utf-8"); + run_jj(&["-R", bob_str, "bookmark", "create", "reserved", "-r", "@"]); + run_jj(&["-R", bob_str, "bookmark", "untrack", "reserved@origin"]); + let repo = Repo::open(&bob_path).expect("open bob repo"); + let feature = repo.show_summary("@").expect("show bob feature").info; + let local_before = repo + .list_bookmarks() + .expect("list bookmarks") + .into_iter() + .find(|bookmark| bookmark.name == "reserved") + .expect("local reserved bookmark"); + assert_eq!(local_before.change_id.id, feature.change_id.id); + assert!( + !local_before + .tracked_remotes + .iter() + .any(|remote| remote == "origin"), + "fixture must keep reserved@origin untracked" + ); + + let error = repo + .submit_stack(vec![submit_layer( + feature.change_id.id.clone(), + "reserved", + "Bob feature", + )]) + .expect_err("diverged origin bookmark collision must fail"); + + assert!( + error + .to_string() + .contains("Bookmark \"reserved\" already belongs to change"), + "unexpected error: {error}" + ); + let local_after = repo + .list_bookmarks() + .expect("list bookmarks after failure") + .into_iter() + .find(|bookmark| bookmark.name == "reserved") + .expect("local reserved bookmark preserved"); + assert_eq!(local_after.change_id.id, feature.change_id.id); +} + +fn remote_bookmark_fixture() -> (tempfile::TempDir, PathBuf) { + let work_dir = tempfile::tempdir().expect("create work dir"); + let bare_path = work_dir.path().join("origin.git"); + let alice_path = work_dir.path().join("alice"); + let bob_path = work_dir.path().join("bob"); + let bare_str = bare_path.to_str().expect("bare path utf-8"); + let alice_str = alice_path.to_str().expect("alice path utf-8"); + let bob_str = bob_path.to_str().expect("bob path utf-8"); + + run_command( + "git", + &[ + "init".into(), + "--bare".into(), + "--initial-branch=main".into(), + bare_str.into(), + ], + Command::new("git").args(["init", "--bare", "--initial-branch=main", bare_str]), + ); + run_jj(&["git", "init", "--colocate", alice_str]); + run_jj(&[ + "-R", + alice_str, + "config", + "set", + "--repo", + "user.name", + "Alice", + ]); + run_jj(&[ + "-R", + alice_str, + "config", + "set", + "--repo", + "user.email", + "alice@example.com", + ]); + fs::write(alice_path.join("main.txt"), "main\n").expect("write main"); + run_jj(&["-R", alice_str, "describe", "-m", "main"]); + run_jj(&["-R", alice_str, "bookmark", "create", "main", "-r", "@"]); + run_jj(&["-R", alice_str, "new", "-m", "reserved remote feature"]); + fs::write(alice_path.join("reserved.txt"), "reserved\n").expect("write feature"); + run_jj(&["-R", alice_str, "bookmark", "create", "reserved", "-r", "@"]); + run_git(&alice_path, &["remote", "add", "origin", bare_str]); + run_jj(&[ + "-R", + alice_str, + "git", + "push", + "--bookmark", + "main", + "--bookmark", + "reserved", + "--remote", + "origin", + ]); + + run_jj(&["git", "clone", "--colocate", bare_str, bob_str]); + run_jj(&["-R", bob_str, "new", "main", "-m", "bob feature"]); + (work_dir, bob_path) +} diff --git a/docs/guide.html b/docs/guide.html index 1527d5e9..2b5d8de3 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -358,7 +358,7 @@

GPUI Shell Alpha #

  • Build and run it from source with just gpui or just gpui /path/to/repo.
  • GPUI targets a shared native shell for macOS, Linux, and Windows while the released macOS app remains SwiftUI.
  • Current GPUI coverage includes graph browsing, diffs, file history, annotate, evolog, file review, review notes, bookmark manager, filesystem refresh, command palette, raw jj commands, searchable help topics that open this guide, native appearance tracking, SVG previews, rendered Markdown previews, HTML external open, diff text selection/copy, file multi-select in the file column, workspace windows, and a Linux jayjay command-line install in Settings > Tools.
  • -
  • Early write coverage includes editing descriptions, saving the working-copy description with the Describe button, committing from the commit box, AI-generated commit messages via the codex or claude CLIs, splitting or committing selected files into a new change, batch file actions on the selection (restore to parent, delete from disk, ignore & untrack, mark reviewed), creating and forgetting workspaces, and abandoning selected working-copy lines from the diff gutter.
  • +
  • Early write coverage includes editing descriptions, saving the working-copy description with the Describe button, committing from the commit box, AI-generated commit messages via the codex or claude CLIs, splitting or committing selected files into a new change, batch file actions on the selection (restore to parent, delete from disk, ignore & untrack, mark reviewed), creating and forgetting workspaces, abandoning selected working-copy lines from the diff gutter, and a dedicated multi-file Diff Edit view with per-file cards, keep-only-selected Done, move to working copy, new child, and new parallel destinations.
  • Remaining GPUI work is tracked in the roadmap.
  • diff --git a/scripts/render-dock-icons.swift b/scripts/render-dock-icons.swift new file mode 100644 index 00000000..0dd793d1 --- /dev/null +++ b/scripts/render-dock-icons.swift @@ -0,0 +1,88 @@ +#!/usr/bin/env swift + +import AppKit + +guard CommandLine.arguments.count == 4 else { + fputs("usage: render-dock-icons.swift BIRD_PNG LIGHT_PNG DARK_PNG\n", stderr) + exit(2) +} + +let birdURL = URL(fileURLWithPath: CommandLine.arguments[1]) +guard let birdImage = NSImage(contentsOf: birdURL) else { + fputs("Could not load jaybird artwork at \(birdURL.path)\n", stderr) + exit(1) +} + +let canvasSize = 1024 +let canvasRect = NSRect(x: 0, y: 0, width: canvasSize, height: canvasSize) +let iconRect = canvasRect.insetBy(dx: 100, dy: 100) +let iconPath = NSBezierPath(roundedRect: iconRect, xRadius: 205, yRadius: 205) + +func render(background: NSColor, border: NSColor?, shadowAlpha: CGFloat, to outputPath: String) { + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: canvasSize, + pixelsHigh: canvasSize, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ), let context = NSGraphicsContext(bitmapImageRep: bitmap) + else { + fputs("Could not create Dock icon bitmap\n", stderr) + exit(1) + } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.imageInterpolation = .high + NSColor.clear.setFill() + canvasRect.fill() + + let shadow = NSShadow() + shadow.shadowColor = .black.withAlphaComponent(shadowAlpha) + shadow.shadowBlurRadius = 46 + shadow.shadowOffset = NSSize(width: 0, height: -18) + shadow.set() + background.setFill() + iconPath.fill() + + NSGraphicsContext.saveGraphicsState() + iconPath.addClip() + birdImage.draw(in: iconRect, from: .zero, operation: .sourceOver, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + + if let border { + border.setStroke() + iconPath.lineWidth = 8 + iconPath.stroke() + } + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.representation(using: .png, properties: [:]) else { + fputs("Could not encode Dock icon PNG\n", stderr) + exit(1) + } + do { + try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic) + } catch { + fputs("Could not write Dock icon to \(outputPath): \(error)\n", stderr) + exit(1) + } +} + +render( + background: .white, + border: nil, + shadowAlpha: 0.30, + to: CommandLine.arguments[2] +) +render( + background: NSColor(srgbRed: 0.055, green: 0.063, blue: 0.082, alpha: 1), + border: .white.withAlphaComponent(0.10), + shadowAlpha: 0.45, + to: CommandLine.arguments[3] +) diff --git a/scripts/verify-release-archive.sh b/scripts/verify-release-archive.sh index 62fc34ac..79b23bb4 100755 --- a/scripts/verify-release-archive.sh +++ b/scripts/verify-release-archive.sh @@ -48,6 +48,18 @@ fi app_path="${apps[0]}" +dock_plugin="$app_path/Contents/PlugIns/JayJayDockTilePlugin.docktileplugin" +if [[ ! -x "$dock_plugin/Contents/MacOS/JayJayDockTilePlugin" ]]; then + echo "Error: Dock tile plugin is missing from the app bundle: $dock_plugin" >&2 + exit 1 +fi + +principal_class="$(defaults read "$dock_plugin/Contents/Info" NSPrincipalClass 2>/dev/null || true)" +if [[ "$principal_class" != "JayJayDockTilePlugin" ]]; then + echo "Error: Dock tile plugin has an unexpected principal class: $principal_class" >&2 + exit 1 +fi + appledouble_file="$(find "$app_path" -name '._*' -print -quit)" if [[ -n "$appledouble_file" ]]; then echo "Error: extracted app contains AppleDouble file: $appledouble_file" >&2 diff --git a/shell/gpui/src/app/actions.rs b/shell/gpui/src/app/actions.rs index e8a93f6c..10c9fd06 100644 --- a/shell/gpui/src/app/actions.rs +++ b/shell/gpui/src/app/actions.rs @@ -34,6 +34,7 @@ actions!( ClearRecentRepositories, Quit, SaveNoteComposer, + SubmitStackedPr, NewWorkspace ] ); @@ -43,3 +44,54 @@ actions!( pub struct OpenRecentRepository { pub path: String, } + +/// The app keymap, shared by `main` and the component-test harness so key dispatch in tests matches production. +pub fn app_key_bindings() -> Vec { + let mod_key = crate::platform::MOD_KEY; + let mut key_bindings = vec![ + gpui::KeyBinding::new(format!("{mod_key}-o").as_str(), OpenRepository, None), + gpui::KeyBinding::new(format!("{mod_key}-,").as_str(), OpenSettings, None), + gpui::KeyBinding::new(format!("{mod_key}-q").as_str(), Quit, None), + gpui::KeyBinding::new(format!("{mod_key}-w").as_str(), CloseWindow, None), + gpui::KeyBinding::new("escape", Dismiss, None), + gpui::KeyBinding::new(format!("{mod_key}-r").as_str(), Refresh, None), + gpui::KeyBinding::new(format!("{mod_key}-+").as_str(), ZoomIn, None), + gpui::KeyBinding::new(format!("{mod_key}--").as_str(), ZoomOut, None), + gpui::KeyBinding::new(format!("{mod_key}-0").as_str(), ResetZoom, None), + gpui::KeyBinding::new( + format!("{mod_key}-shift-p").as_str(), + OpenCommandPalette, + None, + ), + gpui::KeyBinding::new( + format!("{mod_key}-shift-b").as_str(), + OpenBookmarkManager, + None, + ), + gpui::KeyBinding::new( + format!("{mod_key}-shift-u").as_str(), + OpenOperationLog, + None, + ), + gpui::KeyBinding::new( + format!("{mod_key}-alt-f").as_str(), + ShowRepoInFileManager, + None, + ), + gpui::KeyBinding::new(format!("{mod_key}-f").as_str(), OpenFind, None), + gpui::KeyBinding::new(format!("{mod_key}-c").as_str(), CopyDiffSelection, None), + // Scoped to the "NoteComposer" key context, not bare "TextArea", so mod+Return saves the note without binding on every other TextArea (commit box, edit description, ...). + gpui::KeyBinding::new( + format!("{mod_key}-enter").as_str(), + SaveNoteComposer, + Some("NoteComposer"), + ), + gpui::KeyBinding::new( + "enter", + SubmitStackedPr, + Some("StackedPrPanel && !StackedPrInput"), + ), + ]; + key_bindings.extend(crate::ui::text_area::key_bindings(mod_key)); + key_bindings +} diff --git a/shell/gpui/src/diff/diff_view/render.rs b/shell/gpui/src/diff/diff_view/render.rs index 836a36a9..e822fe12 100644 --- a/shell/gpui/src/diff/diff_view/render.rs +++ b/shell/gpui/src/diff/diff_view/render.rs @@ -34,7 +34,12 @@ pub fn diff_view( let t = theme(cx).clone(); let Some(hunk) = state.hunk else { - return placeholder("Select a file", &t).into_any_element(); + let message = if state.no_changes { + "No Files Changed" + } else { + "Select a file" + }; + return placeholder(message, &t).into_any_element(); }; let is_annotating = matches!(state.detail_mode, DetailMode::Annotate); diff --git a/shell/gpui/src/diff/diff_view/state.rs b/shell/gpui/src/diff/diff_view/state.rs index ae345154..8255ac4f 100644 --- a/shell/gpui/src/diff/diff_view/state.rs +++ b/shell/gpui/src/diff/diff_view/state.rs @@ -53,6 +53,7 @@ pub enum DetailMode { pub struct DiffViewState<'a> { pub hunk: Option<&'a DiffHunk>, + pub no_changes: bool, pub file_diff: Option<&'a FileDiff>, pub loaded_projection: Option<&'a DiffProjection>, pub active_projection_preview: bool, diff --git a/shell/gpui/src/diff/diff_view/unified_body.rs b/shell/gpui/src/diff/diff_view/unified_body.rs index 6f01dbe4..db94c731 100644 --- a/shell/gpui/src/diff/diff_view/unified_body.rs +++ b/shell/gpui/src/diff/diff_view/unified_body.rs @@ -55,6 +55,7 @@ pub(super) fn unified_body( range .map(|ix| { gutter_row_at( + view, ix, &gutter_rendered, &gutter_lines, @@ -139,6 +140,7 @@ pub(super) fn unified_body( #[allow(clippy::too_many_arguments)] fn gutter_row_at( + _view: &mut RepoWindow, ix: usize, rendered: &DiffRenderRows, lines: &[WrappedDiffLine], diff --git a/shell/gpui/src/diff/mod.rs b/shell/gpui/src/diff/mod.rs index 91002853..40f4ad6e 100644 --- a/shell/gpui/src/diff/mod.rs +++ b/shell/gpui/src/diff/mod.rs @@ -4,7 +4,7 @@ mod diff_view; mod file_column; pub(crate) mod file_status; mod image_diff; -mod line; +pub(crate) mod line; mod markdown_diff; mod media_diff; pub(crate) mod projection; diff --git a/shell/gpui/src/main.rs b/shell/gpui/src/main.rs index 9d95ba39..3de7de8d 100644 --- a/shell/gpui/src/main.rs +++ b/shell/gpui/src/main.rs @@ -2,20 +2,13 @@ use std::borrow::Cow; use std::path::PathBuf; use gpui::{ - App, AppContext, AssetSource, Bounds, Focusable, KeyBinding, Point, SharedString, Size, - TitlebarOptions, WindowBounds, WindowOptions, px, size, + App, AppContext, AssetSource, Bounds, Focusable, Point, SharedString, Size, TitlebarOptions, + WindowBounds, WindowOptions, px, size, }; -use jayjay_gpui::app::actions::{ - CloseWindow, CopyDiffSelection, Dismiss, OpenBookmarkManager, OpenCommandPalette, OpenFind, - OpenOperationLog, OpenRepository, OpenSettings, Quit, Refresh, ResetZoom, SaveNoteComposer, - ShowRepoInFileManager, ZoomIn, ZoomOut, -}; use jayjay_gpui::app::config::{AppConfig, AppConfigStore}; use jayjay_gpui::app::theme::{Theme, observe_window_appearance}; -use jayjay_gpui::platform::MOD_KEY; use jayjay_gpui::repo::RepoWindow; -use jayjay_gpui::ui::text_area; const LUCIDE_FONT: &[u8] = include_bytes!("../assets/fonts/Lucide.ttf"); const REFRESH_CW_SVG: &[u8] = include_bytes!("../assets/icons/refresh-cw.svg"); @@ -80,48 +73,7 @@ fn main() { cx.window_appearance(), )); - let mod_key = MOD_KEY; - let mut key_bindings = vec![ - KeyBinding::new(format!("{mod_key}-o").as_str(), OpenRepository, None), - KeyBinding::new(format!("{mod_key}-,").as_str(), OpenSettings, None), - KeyBinding::new(format!("{mod_key}-q").as_str(), Quit, None), - KeyBinding::new(format!("{mod_key}-w").as_str(), CloseWindow, None), - KeyBinding::new("escape", Dismiss, None), - KeyBinding::new(format!("{mod_key}-r").as_str(), Refresh, None), - KeyBinding::new(format!("{mod_key}-+").as_str(), ZoomIn, None), - KeyBinding::new(format!("{mod_key}--").as_str(), ZoomOut, None), - KeyBinding::new(format!("{mod_key}-0").as_str(), ResetZoom, None), - KeyBinding::new( - format!("{mod_key}-shift-p").as_str(), - OpenCommandPalette, - None, - ), - KeyBinding::new( - format!("{mod_key}-shift-b").as_str(), - OpenBookmarkManager, - None, - ), - KeyBinding::new( - format!("{mod_key}-shift-u").as_str(), - OpenOperationLog, - None, - ), - KeyBinding::new( - format!("{mod_key}-alt-f").as_str(), - ShowRepoInFileManager, - None, - ), - KeyBinding::new(format!("{mod_key}-f").as_str(), OpenFind, None), - KeyBinding::new(format!("{mod_key}-c").as_str(), CopyDiffSelection, None), - // Scoped to the "NoteComposer" key context, not bare "TextArea", so mod+Return saves the note without binding on every other TextArea (commit box, edit description, ...). - KeyBinding::new( - format!("{mod_key}-enter").as_str(), - SaveNoteComposer, - Some("NoteComposer"), - ), - ]; - key_bindings.extend(text_area::key_bindings(mod_key)); - cx.bind_keys(key_bindings); + cx.bind_keys(jayjay_gpui::app::actions::app_key_bindings()); let initial_bounds = if cfg.window.is_set() { Bounds { diff --git a/shell/gpui/src/repo/mod.rs b/shell/gpui/src/repo/mod.rs index e04ebbfd..8aa3c589 100644 --- a/shell/gpui/src/repo/mod.rs +++ b/shell/gpui/src/repo/mod.rs @@ -1,7 +1,10 @@ pub mod revset; +mod stacked_pr; pub mod toggles; pub mod toolbar; pub mod view_model; pub mod window; +pub(crate) use stacked_pr::CoreStackedPrProvider; +pub use stacked_pr::StackedPrProvider; pub use window::{ActivePane, RepoWindow, open_repo_window}; diff --git a/shell/gpui/src/repo/stacked_pr.rs b/shell/gpui/src/repo/stacked_pr.rs new file mode 100644 index 00000000..a8788d3f --- /dev/null +++ b/shell/gpui/src/repo/stacked_pr.rs @@ -0,0 +1,18 @@ +use jayjay_core::{CoreResult, Repo, Stack, StackedPrResult, SubmitStackLayer}; + +pub trait StackedPrProvider: Send + Sync { + fn detect(&self, repo: &Repo, base_rev: &str, tip_rev: &str) -> CoreResult; + fn submit(&self, repo: &Repo, layers: Vec) -> CoreResult; +} + +pub(crate) struct CoreStackedPrProvider; + +impl StackedPrProvider for CoreStackedPrProvider { + fn detect(&self, repo: &Repo, base_rev: &str, tip_rev: &str) -> CoreResult { + repo.detect_stack(base_rev, tip_rev) + } + + fn submit(&self, repo: &Repo, layers: Vec) -> CoreResult { + repo.submit_stack(layers) + } +} diff --git a/shell/gpui/src/repo/view_model/loaders/diff.rs b/shell/gpui/src/repo/view_model/loaders/diff.rs index b14f1c09..a23c2e4e 100644 --- a/shell/gpui/src/repo/view_model/loaders/diff.rs +++ b/shell/gpui/src/repo/view_model/loaders/diff.rs @@ -4,7 +4,7 @@ use gpui::Context; use jayjay_core::diff::FileDiff; use jayjay_core::{DiffHunk, DiffPreview, DiffProjectionMode}; -use super::super::{LoadedDiff, RepoViewModel}; +use super::super::{DiffLoadState, LoadedDiff, RepoViewModel}; use super::diff_compute::{compute_diff_blocking, diff_cache_key}; use crate::diff::{DetailMode, projection}; @@ -37,6 +37,7 @@ impl RepoViewModel { projection_mode, self.ignore_whitespace, ); + self.diff_load_failures.remove(&cache_key); if let Some(cached) = self.diff_cache.get(&cache_key).cloned() { self.current_diff = Some(cached.diff); self.current_projection = cached.projection; @@ -115,6 +116,7 @@ impl RepoViewModel { ); } Err(error) => { + vm.diff_load_failures.insert(cache_key); vm.current_diff = Some(Arc::new(FileDiff { path: fallback_path, language: String::new(), @@ -162,13 +164,10 @@ impl RepoViewModel { let Some(repo) = self.repo.clone() else { return; }; - let Some(rev) = self - .selected - .and_then(|i| self.graph.changes.get(i)) - .map(|c| c.change_id.clone()) - else { + let Some(rev) = self.selected_revision() else { return; }; + let generation = self.loading.change_gen; let ignore_whitespace = self.ignore_whitespace; let pending: Vec<_> = hunks .iter() @@ -182,7 +181,11 @@ impl RepoViewModel { projection_mode, ) }) - .filter(|(key, _, _)| !self.diff_cache.contains_key(key)) + .filter(|(key, _, _)| { + !self.diff_cache.contains_key(key) + && !self.diff_preloads_in_flight.contains(key) + && !self.diff_load_failures.contains(key) + }) .collect(); if pending.is_empty() { @@ -190,6 +193,7 @@ impl RepoViewModel { } for (cache_key, hunk, projection_mode) in pending { + self.diff_preloads_in_flight.insert(cache_key.clone()); let repo = repo.clone(); let rev = rev.clone(); let hunk_path = hunk.path.clone(); @@ -205,22 +209,51 @@ impl RepoViewModel { ignore_whitespace, ) }, - move |vm, result, _cx| { - let Ok(loaded) = result else { + move |vm, result, cx| { + if vm.loading.change_gen != generation { return; - }; - vm.diff_cache.entry(cache_key).or_insert(LoadedDiff { - diff: Arc::new(loaded.file_diff), - projection: loaded.projection, - svg_preview: loaded.svg_preview.map(Arc::new), - markdown_preview: loaded.markdown_preview.map(Arc::new), - // `or_insert` never overwrites, so planting `None` here would permanently starve "Abandon Selected Lines" for any file later selected via this cache entry. - old_content: Some(loaded.old_content), - new_content: Some(loaded.new_content), - }); - vm.apply_hunk_previews(&hunk_path, loaded.old_preview, loaded.new_preview); + } + vm.diff_preloads_in_flight.remove(&cache_key); + match result { + Ok(loaded) => { + vm.diff_load_failures.remove(&cache_key); + vm.diff_cache.entry(cache_key).or_insert(LoadedDiff { + diff: Arc::new(loaded.file_diff), + projection: loaded.projection, + svg_preview: loaded.svg_preview.map(Arc::new), + markdown_preview: loaded.markdown_preview.map(Arc::new), + // `or_insert` never overwrites, so planting `None` here would permanently starve "Abandon Selected Lines" for any file later selected via this cache entry. + old_content: Some(loaded.old_content), + new_content: Some(loaded.new_content), + }); + vm.apply_hunk_previews( + &hunk_path, + loaded.old_preview, + loaded.new_preview, + ); + } + Err(_) => { + vm.diff_load_failures.insert(cache_key); + } + } + cx.notify(); }, ); } } + + pub(in crate::repo) fn diff_load_state(&self, hunk: &DiffHunk) -> DiffLoadState { + let Some(rev) = self.selected_revision() else { + return DiffLoadState::Missing; + }; + let projection_mode = projection::request_mode(hunk.projection.as_ref(), false); + let key = diff_cache_key(None, &rev, hunk, projection_mode, self.ignore_whitespace); + if let Some(loaded) = self.diff_cache.get(&key) { + DiffLoadState::Loaded(loaded.clone()) + } else if self.diff_load_failures.contains(&key) { + DiffLoadState::Failed + } else { + DiffLoadState::Missing + } + } } diff --git a/shell/gpui/src/repo/view_model/mod.rs b/shell/gpui/src/repo/view_model/mod.rs index a2d33792..0d185779 100644 --- a/shell/gpui/src/repo/view_model/mod.rs +++ b/shell/gpui/src/repo/view_model/mod.rs @@ -1,7 +1,7 @@ //! `RepoViewModel`: state + async loaders for a single repo window. mod loaders; -mod mutations; +pub(crate) mod mutations; mod mutations_files; mod refresh_indicator; mod selection; @@ -100,6 +100,8 @@ pub struct RepoViewModel { pub current_diff_old_content: Option>, pub current_diff_new_content: Option>, pub diff_cache: HashMap, + diff_preloads_in_flight: HashSet, + diff_load_failures: HashSet, pub change_stats: Option, pub working_copy_stats: Option, pub current_operation_description: String, @@ -137,6 +139,12 @@ pub struct LoadedDiff { pub new_content: Option>, } +pub(in crate::repo) enum DiffLoadState { + Missing, + Failed, + Loaded(LoadedDiff), +} + #[derive(Clone)] pub struct SvgPreviewContent { pub old: Option, @@ -235,6 +243,8 @@ impl RepoViewModel { current_diff_old_content: None, current_diff_new_content: None, diff_cache: HashMap::new(), + diff_preloads_in_flight: HashSet::new(), + diff_load_failures: HashSet::new(), change_stats: None, working_copy_stats: None, current_operation_description: String::new(), @@ -279,6 +289,8 @@ impl RepoViewModel { current_diff_old_content: None, current_diff_new_content: None, diff_cache: HashMap::new(), + diff_preloads_in_flight: HashSet::new(), + diff_load_failures: HashSet::new(), change_stats: None, working_copy_stats: None, current_operation_description: String::new(), @@ -347,6 +359,12 @@ impl RepoViewModel { .and_then(|f| self.selected_file_ix.and_then(|ix| f.get(ix))) } + fn clear_diff_cache_state(&mut self) { + self.diff_cache.clear(); + self.diff_preloads_in_flight.clear(); + self.diff_load_failures.clear(); + } + /// The shared gate every review surface (marks, notes) uses: a bare `is_working_copy` check would wrongly pass in compare mode, where the displayed diff is an interdiff and review state doesn't apply. pub fn shows_review_controls(&self) -> bool { self.selected_change().is_some_and(|c| c.is_working_copy) && self.compare.is_none() diff --git a/shell/gpui/src/repo/view_model/mutations.rs b/shell/gpui/src/repo/view_model/mutations.rs index 23cee77e..ee384654 100644 --- a/shell/gpui/src/repo/view_model/mutations.rs +++ b/shell/gpui/src/repo/view_model/mutations.rs @@ -1,11 +1,36 @@ use gpui::Context; use jayjay_core::{ - CoreResult, DiffEditDestination, DiffEditFileSelection, FetchResult, init_jj_git_repo, + CoreResult, DiffEditDestination, DiffEditFileSelection, FetchResult, StackedPrResult, + SubmitStackLayer, init_jj_git_repo, }; +use std::sync::Arc; + use super::RepoViewModel; +pub struct DiffEditApplyRequest { + pub rev: String, + pub destination: DiffEditDestination, + pub selections: Vec, + pub message: String, + pub ignore_whitespace: bool, + pub restore_path: String, +} + impl RepoViewModel { + pub fn submit_stack( + &mut self, + provider: Arc, + layers: Vec, + cx: &mut Context, + ) -> gpui::Task> { + self.repo_result_task( + cx, + move |repo| provider.submit(&repo, layers), + |vm, _result, cx| vm.refresh(false, cx), + ) + } + pub fn describe_change( &mut self, rev: String, @@ -112,6 +137,30 @@ impl RepoViewModel { ) } + pub fn apply_diff_edit( + &mut self, + request: DiffEditApplyRequest, + cx: &mut Context, + ) -> gpui::Task> { + let restore_path = request.restore_path; + self.repo_write_task( + cx, + move |repo| { + repo.apply_diff_selection( + &request.rev, + request.destination, + &request.selections, + &request.message, + request.ignore_whitespace, + ) + }, + move |vm, cx| { + vm.pending_file_selection = Some(restore_path); + vm.refresh(false, cx); + }, + ) + } + pub fn resolve_with_tool( &mut self, rev: String, diff --git a/shell/gpui/src/repo/view_model/selection.rs b/shell/gpui/src/repo/view_model/selection.rs index 94d946b0..f1982cdc 100644 --- a/shell/gpui/src/repo/view_model/selection.rs +++ b/shell/gpui/src/repo/view_model/selection.rs @@ -34,7 +34,7 @@ impl RepoViewModel { self.current_markdown_preview = None; self.current_diff_old_content = None; self.current_diff_new_content = None; - self.diff_cache.clear(); + self.clear_diff_cache_state(); self.change_stats = None; self.loading.files = true; self.loading.diff = false; @@ -196,6 +196,9 @@ impl RepoViewModel { self.current_projection = None; self.current_svg_preview = None; self.current_markdown_preview = None; + self.current_diff_old_content = None; + self.current_diff_new_content = None; + self.clear_diff_cache_state(); self.change_stats = None; self.loading.files = true; self.loading.diff = false; @@ -267,7 +270,7 @@ impl RepoViewModel { self.change_stats = None; self.loading.files = false; self.loading.diff = false; - self.diff_cache.clear(); + self.clear_diff_cache_state(); cx.notify(); } } diff --git a/shell/gpui/src/repo/window/actions.rs b/shell/gpui/src/repo/window/actions.rs index dbe1a366..375baead 100644 --- a/shell/gpui/src/repo/window/actions.rs +++ b/shell/gpui/src/repo/window/actions.rs @@ -22,6 +22,9 @@ impl RepoWindow { && let Some(selected_ix) = selected && selected_ix != ix { + if self.diff_edit_active() { + self.exit_diff_edit(cx); + } self.active_pane = ActivePane::Sidebar; self.find.matches.clear(); self.find.current = 0; @@ -34,6 +37,9 @@ impl RepoWindow { } pub fn select_change(&mut self, ix: usize, cx: &mut Context) { + if self.diff_edit_active() { + self.exit_diff_edit(cx); + } self.active_pane = ActivePane::Sidebar; self.find.matches.clear(); self.find.current = 0; @@ -84,13 +90,50 @@ impl RepoWindow { rev: String, description: String, cx: &mut Context, + ) { + self.open_description_modal( + rev.clone(), + description, + TextModalAction::EditDescription { rev }, + cx, + ); + } + + pub(super) fn open_diff_edit_description(&mut self, cx: &mut Context) { + if !self.diff_edit.active || self.diff_edit.working_copy { + return; + } + let subtitle = self + .diff_edit + .change_id + .as_deref() + .unwrap_or_default() + .chars() + .take(12) + .collect::(); + self.open_description_modal( + subtitle, + self.diff_edit.message.clone(), + TextModalAction::DiffEditDescription { + session: self.diff_edit.session, + }, + cx, + ); + } + + fn open_description_modal( + &mut self, + subtitle: String, + description: String, + action: TextModalAction, + cx: &mut Context, ) { let input = cx.new(|cx| TextArea::new(description, "Description", true, 190., cx)); self.text_modal = Some(TextModalState { title: "Edit Description".into(), - subtitle: rev.clone().into(), + subtitle: subtitle.into(), primary_label: "Save".into(), - action: TextModalAction::EditDescription { rev }, + action, input, focus_pending: true, context: None, @@ -143,6 +186,12 @@ impl RepoWindow { .update(cx, |vm, cx| vm.describe_change(rev, text, cx)); task.detach(); } + TextModalAction::DiffEditDescription { session } => { + self.text_modal = None; + if self.diff_edit.active && self.diff_edit.session == session { + self.diff_edit.message = text; + } + } TextModalAction::CreateBookmark { rev } => { let name = text.trim().to_string(); if name.is_empty() { diff --git a/shell/gpui/src/repo/window/commit_ai.rs b/shell/gpui/src/repo/window/commit_ai.rs index 54dbbdbd..c20b8f19 100644 --- a/shell/gpui/src/repo/window/commit_ai.rs +++ b/shell/gpui/src/repo/window/commit_ai.rs @@ -17,6 +17,10 @@ pub trait CommitMessageProvider: Send + Sync { fn detect(&self) -> Option; /// Blocking generation from a working-copy diff summary; always runs on the background executor. fn generate(&self, diff_summary: &str) -> Result; + /// Generate a bookmark-name slug from a change description. + fn generate_branch_name(&self, description: &str) -> Result { + self.generate(description) + } } /// Real provider chain: codex first, then claude (Apple Intelligence stays SwiftUI-only). @@ -33,6 +37,12 @@ impl CommitMessageProvider for CliCommitMessageProvider { "AI generation failed; check that codex or claude works in a terminal".to_owned() }) } + + fn generate_branch_name(&self, description: &str) -> Result { + jayjay_core::generate_branch_name_cli(description).ok_or_else(|| { + "AI naming failed; check that codex or claude works in a terminal".to_owned() + }) + } } pub(crate) struct CommitAiState { diff --git a/shell/gpui/src/repo/window/detail/description.rs b/shell/gpui/src/repo/window/detail/description.rs index 7ec59c82..997d6c07 100644 --- a/shell/gpui/src/repo/window/detail/description.rs +++ b/shell/gpui/src/repo/window/detail/description.rs @@ -27,7 +27,7 @@ pub(super) fn description_block( .join("\n"); let body = body.trim().to_string(); - let can_show_edit_diff = !change.has_conflict && !change.is_empty; + let can_show_edit_diff = !change.has_conflict && !change.is_empty && !change.is_immutable; let header = div() .flex() @@ -47,7 +47,7 @@ pub(super) fn description_block( cx, )) .child(div().flex_1()) - .child(edit_diff_button(can_show_edit_diff, t)); + .child(edit_diff_button(can_show_edit_diff, t, cx)); let mut block = div() .flex() @@ -117,7 +117,7 @@ fn edit_button(can_edit: bool, t: &Theme, cx: &mut Context) -> AnyEl .into_any_element() } -fn edit_diff_button(visible: bool, t: &Theme) -> AnyElement { +fn edit_diff_button(visible: bool, t: &Theme, cx: &mut Context) -> AnyElement { if !visible { return div().into_any_element(); } @@ -131,11 +131,13 @@ fn edit_diff_button(visible: bool, t: &Theme) -> AnyElement { .h(px(22.)) .rounded_sm() .bg(rgb(t.toggle_inactive_bg)) - .text_color(rgb(t.fg_faint)) + .text_color(rgb(t.toggle_inactive_fg)) .text_size(px(11.)) - .opacity(0.55) .debug_selector(|| "edit-diff".to_owned()) - .tooltip(text_tooltip("Diff edit mode is coming to this shell")) + .cursor_pointer() + .hover(|s| s.bg(rgb(t.row_alt_bg))) + .tooltip(text_tooltip("Open Diff Edit Mode")) + .on_click(cx.listener(|view, _, _, cx| view.enter_diff_edit(cx))) .child("Edit Diff...") .into_any_element() } diff --git a/shell/gpui/src/repo/window/detail/mod.rs b/shell/gpui/src/repo/window/detail/mod.rs index 2ca9e9aa..51b54780 100644 --- a/shell/gpui/src/repo/window/detail/mod.rs +++ b/shell/gpui/src/repo/window/detail/mod.rs @@ -67,6 +67,7 @@ pub(super) fn detail_pane( let diff_state = DiffViewState { hunk: selected_hunk.as_ref(), + no_changes: file_count == Some(0), file_diff: current_diff.as_deref(), loaded_projection: current_projection.as_ref(), active_projection_preview, diff --git a/shell/gpui/src/repo/window/diff_edit.rs b/shell/gpui/src/repo/window/diff_edit.rs new file mode 100644 index 00000000..90320b2b --- /dev/null +++ b/shell/gpui/src/repo/window/diff_edit.rs @@ -0,0 +1,239 @@ +use std::collections::BTreeSet; + +use gpui::Context; +use jayjay_core::diff::change_groups; + +use super::diff_edit_state::{checkbox_state, hunk_supports_diff_edit, next_diff_edit_session}; +use super::{DiffEditCheckboxState, DiffEditState, RepoWindow, TextModalAction}; + +impl RepoWindow { + pub fn enter_diff_edit(&mut self, cx: &mut Context) { + if self.diff_edit.active || !self.can_enter_diff_edit(cx) { + return; + } + let (change_id, description, working_copy) = self + .vm + .read(cx) + .selected_change_for_file_ops() + .map(|change| { + ( + change.change_id.id.clone(), + change.description.clone(), + change.is_working_copy, + ) + }) + .unwrap(); + self.diff_edit.active = true; + self.diff_edit.focus_pending = true; + self.diff_edit.session = next_diff_edit_session(); + self.diff_edit.change_id = Some(change_id); + self.diff_edit.working_copy = working_copy; + self.diff_edit.message = description; + self.ensure_diff_edit_files(cx); + if let Some(files) = self.vm.read(cx).files.clone() { + self.vm + .update(cx, |vm, cx| vm.preload_diffs_async(files, cx)); + } + cx.notify(); + } + + pub fn exit_diff_edit(&mut self, cx: &mut Context) { + if self.diff_edit.active { + if self.text_modal.as_ref().is_some_and(|modal| { + matches!(&modal.action, TextModalAction::DiffEditDescription { .. }) + }) { + self.text_modal = None; + } + self.diff_edit = DiffEditState::default(); + cx.notify(); + } + } + + pub fn diff_edit_active(&self) -> bool { + self.diff_edit.active + } + + pub fn diff_edit_selecting_all(&self) -> bool { + !self.diff_edit.select_all_pending.is_empty() + } + + pub fn diff_edit_selected(&self, path: &str) -> BTreeSet { + self.diff_edit + .selected + .get(path) + .cloned() + .unwrap_or_default() + } + + pub fn diff_edit_file_state(&self, path: &str) -> DiffEditCheckboxState { + let Some(loaded) = self.diff_edit.loaded_files.get(path) else { + return DiffEditCheckboxState::None; + }; + checkbox_state(self.diff_edit.selected.get(path), &loaded.changed) + } + + pub fn toggle_diff_edit_display_line( + &mut self, + path: &str, + display_line: u32, + cx: &mut Context, + ) { + let Some(loaded) = self.diff_edit.loaded_files.get(path) else { + return; + }; + let Some(full_line) = loaded.display_to_full.get(&display_line).copied() else { + return; + }; + if !loaded.changed.contains(&full_line) { + return; + } + let selected = self.diff_edit.selected.entry(path.to_owned()).or_default(); + if !selected.remove(&full_line) { + selected.insert(full_line); + } + self.refresh_diff_edit_summary(); + cx.notify(); + } + + pub fn select_diff_edit_display_group( + &mut self, + path: &str, + display_line: u32, + cx: &mut Context, + ) { + let Some(loaded) = self.diff_edit.loaded_files.get(path) else { + return; + }; + let Some(group) = change_groups(&loaded.display_diff.lines) + .into_iter() + .find(|group| (group.start_line..=group.end_line).contains(&display_line)) + else { + return; + }; + let lines: Vec = (group.start_line..=group.end_line) + .filter_map(|line| loaded.display_to_full.get(&line).copied()) + .filter(|line| loaded.changed.contains(line)) + .collect(); + self.diff_edit + .selected + .entry(path.to_owned()) + .or_default() + .extend(lines); + self.refresh_diff_edit_summary(); + cx.notify(); + } + + pub fn toggle_diff_edit_file(&mut self, path: &str, cx: &mut Context) { + let Some(loaded) = self.diff_edit.loaded_files.get(path) else { + return; + }; + let lines = loaded.changed.clone(); + if lines.is_empty() { + return; + } + let selected = self.diff_edit.selected.entry(path.to_owned()).or_default(); + if lines.is_subset(selected) { + selected.clear(); + } else { + selected.extend(lines.iter().copied()); + } + self.refresh_diff_edit_summary(); + cx.notify(); + } + + pub fn toggle_diff_edit_all(&mut self, cx: &mut Context) { + if self.diff_edit_selecting_all() { + return; + } + let has_selection = self + .diff_edit + .selected + .values() + .any(|lines| !lines.is_empty()); + if has_selection || !self.diff_edit.select_all_pending.is_empty() { + self.diff_edit.select_all_pending.clear(); + for selected in self.diff_edit.selected.values_mut() { + selected.clear(); + } + self.refresh_diff_edit_summary(); + cx.notify(); + return; + } + let (paths, files) = self + .vm + .read(cx) + .files + .as_ref() + .map(|files| { + let paths = files + .iter() + .filter(|hunk| hunk_supports_diff_edit(hunk)) + .map(|hunk| hunk.path.clone()) + .collect(); + (paths, files.clone()) + }) + .unwrap_or_default(); + self.diff_edit.select_all_pending = paths; + self.drain_select_all_pending(); + if self.diff_edit_selecting_all() { + self.ensure_diff_edit_files(cx); + self.vm + .update(cx, |vm, cx| vm.preload_diffs_async(files, cx)); + } + cx.notify(); + } + + fn drain_select_all_pending(&mut self) { + let ready: Vec = self + .diff_edit + .select_all_pending + .iter() + .filter(|path| { + self.diff_edit.loaded_files.contains_key(*path) + || self.diff_edit.known_unsupported.contains(*path) + }) + .cloned() + .collect(); + for path in ready { + self.diff_edit.select_all_pending.remove(&path); + if let Some(loaded) = self.diff_edit.loaded_files.get(&path) { + let lines = (*loaded.changed).clone(); + self.diff_edit.selected.insert(path, lines); + } + } + self.refresh_diff_edit_summary(); + } + + pub(crate) fn sync_diff_edit_loaded_files(&mut self, cx: &mut Context) { + if !self.diff_edit.active { + return; + } + if !self.diff_edit_change_is_current(cx) { + self.exit_diff_edit(cx); + return; + } + self.ensure_diff_edit_files(cx); + } + + pub(crate) fn sync_diff_edit_change(&mut self, cx: &mut Context) { + if self.diff_edit.active && !self.diff_edit_change_is_current(cx) { + self.exit_diff_edit(cx); + } + } + + pub(crate) fn can_enter_diff_edit(&self, cx: &Context) -> bool { + self.vm + .read(cx) + .selected_change_for_file_ops() + .is_some_and(|change| !change.has_conflict && !change.is_empty && !change.is_immutable) + } + + fn diff_edit_change_is_current(&self, cx: &Context) -> bool { + self.vm + .read(cx) + .selected_change_for_file_ops() + .is_some_and(|change| { + self.diff_edit.change_id.as_deref() == Some(change.change_id.id.as_str()) + }) + } +} diff --git a/shell/gpui/src/repo/window/diff_edit_apply.rs b/shell/gpui/src/repo/window/diff_edit_apply.rs new file mode 100644 index 00000000..3a026ec8 --- /dev/null +++ b/shell/gpui/src/repo/window/diff_edit_apply.rs @@ -0,0 +1,197 @@ +use std::collections::BTreeSet; + +use gpui::Context; +use jayjay_core::{DiffEditDestination, DiffEditFileSelection, DiffEditRange, HunkType}; + +use super::RepoWindow; +use super::diff_edit_state::hunk_supports_diff_edit; +use super::diff_edit_view::DiffEditSnapshot; +use crate::repo::view_model::mutations::DiffEditApplyRequest; + +const EMPTY_SELECTION_MESSAGE: &str = + "Select at least one file, hunk, or line before applying diff edit."; +const SELECTION_STILL_LOADING_MESSAGE: &str = + "Wait for Select All to finish loading before applying diff edit."; +const FILES_STILL_LOADING_MESSAGE: &str = + "Wait for all editable files to finish loading before applying diff edit."; + +impl RepoWindow { + pub fn diff_edit_selection_summary(&self) -> (usize, usize) { + self.diff_edit.summary + } + + pub(super) fn diff_edit_selection_text(&self) -> String { + let (lines, files) = self.diff_edit_selection_summary(); + if lines == 0 { + return "Select files, hunks, or line ranges to edit".into(); + } + format!( + "{files} {}, {lines} {} selected", + if files == 1 { "file" } else { "files" }, + if lines == 1 { "line" } else { "lines" } + ) + } + + pub(super) fn diff_edit_should_deselect(&self) -> bool { + !self.diff_edit.select_all_pending.is_empty() + || self + .diff_edit + .selected + .values() + .any(|lines| !lines.is_empty()) + } + + pub fn diff_edit_snapshot(&self) -> DiffEditSnapshot { + let working_copy = self.diff_edit.working_copy; + let (selected_lines, selected_files) = self.diff_edit_selection_summary(); + let destinations = if working_copy { + vec![DiffEditDestination::RemoveFromSource] + } else { + vec![ + DiffEditDestination::NewChild, + DiffEditDestination::NewParallel, + DiffEditDestination::MoveToWorkingCopy, + DiffEditDestination::RemoveFromSource, + ] + }; + DiffEditSnapshot { + active: self.diff_edit.active, + working_copy, + description: self.diff_edit.message.clone(), + destinations, + selected_files, + selected_lines, + } + } + + pub fn set_diff_edit_message(&mut self, message: impl Into, cx: &mut Context) { + self.diff_edit.message = message.into(); + cx.notify(); + } + + pub fn start_diff_edit_apply( + &mut self, + destination: DiffEditDestination, + cx: &mut Context, + ) { + if !self.diff_edit.select_all_pending.is_empty() { + self.show_toast(SELECTION_STILL_LOADING_MESSAGE, cx); + return; + } + if self.diff_edit_selection_summary().0 == 0 { + self.show_toast(EMPTY_SELECTION_MESSAGE, cx); + return; + } + if destination == DiffEditDestination::RemoveFromSource + && !self.diff_edit_inverse_files_ready(cx) + { + self.show_toast(FILES_STILL_LOADING_MESSAGE, cx); + return; + } + let message = self.diff_edit.message.clone(); + self.apply_diff_edit(destination, message, cx); + } + + fn diff_edit_inverse_files_ready(&self, cx: &Context) -> bool { + self.vm.read(cx).files.as_ref().is_some_and(|hunks| { + hunks + .iter() + .filter(|hunk| hunk_supports_diff_edit(hunk)) + .all(|hunk| { + self.diff_edit.loaded_files.contains_key(&hunk.path) + || self.diff_edit.known_unsupported.contains(&hunk.path) + }) + }) + } + + fn apply_diff_edit( + &mut self, + destination: DiffEditDestination, + message: String, + cx: &mut Context, + ) { + let Some(request) = self.build_diff_edit_request(destination, message, cx) else { + self.show_toast(EMPTY_SELECTION_MESSAGE, cx); + return; + }; + self.vm + .update(cx, |vm, cx| vm.apply_diff_edit(request, cx)) + .detach(); + self.exit_diff_edit(cx); + } + + fn build_diff_edit_request( + &self, + destination: DiffEditDestination, + message: String, + cx: &Context, + ) -> Option { + let vm = self.vm.read(cx); + let change = vm.selected_change_for_file_ops()?; + if self.diff_edit.change_id.as_deref() != Some(change.change_id.id.as_str()) { + return None; + } + let hunks = vm.files.as_ref()?; + let inverse = destination == DiffEditDestination::RemoveFromSource; + let mut selections = Vec::new(); + for hunk in hunks.iter().filter(|hunk| hunk_supports_diff_edit(hunk)) { + let Some(loaded) = self.diff_edit.loaded_files.get(&hunk.path) else { + continue; + }; + let selected = self + .diff_edit + .selected + .get(&hunk.path) + .cloned() + .unwrap_or_default(); + let lines = if inverse { + loaded.changed.difference(&selected).copied().collect() + } else { + selected + }; + if let Some(selection) = file_selection(hunk, loaded, &lines) { + selections.push(selection); + } + } + if selections.is_empty() { + return None; + } + Some(DiffEditApplyRequest { + rev: crate::repo::revset::change_revision(change), + destination, + selections, + message, + ignore_whitespace: vm.ignore_whitespace, + restore_path: vm.selected_hunk()?.path.clone(), + }) + } +} + +fn file_selection( + hunk: &jayjay_core::DiffHunk, + loaded: &super::diff_edit_state::DiffEditLoadedFile, + lines: &BTreeSet, +) -> Option { + (!lines.is_empty()).then(|| DiffEditFileSelection { + path: hunk.path.clone(), + old_path: hunk.old_path.clone(), + old_content: (hunk.hunk_type != HunkType::Added).then(|| loaded.old_content.to_string()), + new_content: (hunk.hunk_type != HunkType::Removed).then(|| loaded.new_content.to_string()), + hunk_type: hunk.hunk_type, + line_ranges: contiguous_ranges(lines), + }) +} + +fn contiguous_ranges(lines: &BTreeSet) -> Vec { + let mut ranges = Vec::new(); + for line in lines.iter().copied() { + match ranges.last_mut() { + Some(DiffEditRange { end_line, .. }) if *end_line + 1 == line => *end_line = line, + _ => ranges.push(DiffEditRange { + start_line: line, + end_line: line, + }), + } + } + ranges +} diff --git a/shell/gpui/src/repo/window/diff_edit_cards.rs b/shell/gpui/src/repo/window/diff_edit_cards.rs new file mode 100644 index 00000000..396bf2dd --- /dev/null +++ b/shell/gpui/src/repo/window/diff_edit_cards.rs @@ -0,0 +1,252 @@ +use std::sync::Arc; + +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, Pixels, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, rgba, uniform_list, +}; + +use super::RepoWindow; +use super::diff_edit_gutter::diff_edit_line_row; +use super::diff_edit_rows::{DiffEditCardFile, DiffEditRow, DiffEditRowModel}; +use crate::app::fonts; +use crate::app::theme::{Theme, with_alpha}; +use crate::diff::bounds_capture; +use crate::diff::file_status; +use crate::diff::line::ROW_HEIGHT; +use crate::ui::icons::{self, glyph}; +use crate::ui::scrollbar::vertical_uniform_scrollbar; + +pub(super) fn diff_edit_body( + view: &mut RepoWindow, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + view.ensure_diff_edit_files(cx); + let model = view.diff_edit_row_model(cx); + let count = model.rows.len(); + let theme = Arc::new(t.clone()); + let advance = fonts::mono_advance(cx, px(12.)); + let scroll = view.diff_edit.scroll.clone(); + let bounds = view.diff_edit.bounds.clone(); + let list = uniform_list( + "diff-edit-rows", + count, + cx.processor(move |view, range: std::ops::Range, _window, cx| { + range + .map(|ix| row_at(view, &model, ix, &theme, advance, cx)) + .collect() + }), + ) + .track_scroll(&scroll); + div() + .id("diff-edit-body") + .relative() + .flex_1() + .min_h_0() + .child(bounds_capture(bounds.clone())) + .child(list.h_full()) + .child(vertical_uniform_scrollbar( + scroll, + bounds, + px(count as f32 * ROW_HEIGHT), + t, + cx, + )) + .into_any_element() +} + +fn row_at( + view: &mut RepoWindow, + model: &Arc, + ix: usize, + t: &Theme, + advance: Pixels, + cx: &mut Context, +) -> AnyElement { + match &model.rows[ix] { + DiffEditRow::Notice => notice_row(t), + DiffEditRow::Gap => div().h(px(ROW_HEIGHT)).into_any_element(), + DiffEditRow::HeaderPad { top } => header_pad_row(*top, t), + DiffEditRow::Header(file) => header_row(view, &model.files[*file], t, cx), + DiffEditRow::Line { + file, + line_ix, + full_line, + } => { + let card = &model.files[*file]; + let Some(line) = card + .diff + .as_ref() + .and_then(|diff| diff.lines.get(*line_ix as usize)) + else { + return div().h(px(ROW_HEIGHT)).into_any_element(); + }; + let checked = full_line.is_some_and(|full| { + view.diff_edit + .selected + .get(card.path.as_ref()) + .is_some_and(|selected| selected.contains(&full)) + }); + diff_edit_line_row( + &card.path, + line, + *line_ix + 1, + full_line.is_some(), + checked, + t, + advance, + cx, + ) + } + DiffEditRow::Placeholder { loading, .. } => placeholder_row( + if *loading { + "Loading file diff…" + } else { + "No textual preview available for this file." + }, + t, + ), + } +} + +fn notice_row(t: &Theme) -> AnyElement { + div() + .flex() + .w_full() + .items_center() + .gap(px(8.)) + .h(px(ROW_HEIGHT)) + .px(px(18.)) + .text_size(px(11.)) + .text_color(rgb(t.fg_dim)) + .child(icons::icon(glyph::INFO, 11., t.fg_dim)) + .child("Projected, renamed, and non-text files can be previewed here but are not editable yet.") + .into_any_element() +} + +fn header_bg(t: &Theme) -> u32 { + with_alpha(t.fg, if t.is_dark { 0x12 } else { 0x0a }) +} + +fn header_pad_row(top: bool, t: &Theme) -> AnyElement { + let mut pad = div().size_full().bg(rgba(header_bg(t))); + if top { + pad = pad.rounded_t_lg(); + } else { + pad = pad.rounded_b_lg(); + } + div() + .w_full() + .h(px(ROW_HEIGHT)) + .px(px(18.)) + .child(pad) + .into_any_element() +} + +fn header_row( + view: &RepoWindow, + card: &DiffEditCardFile, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let selected_count = view + .diff_edit + .selected + .get(card.path.as_ref()) + .map(|selected| selected.len()) + .unwrap_or(0); + let mut row = div() + .flex() + .flex_1() + .items_center() + .gap(px(8.)) + .h(px(ROW_HEIGHT)) + .px(px(14.)) + .bg(rgba(header_bg(t))); + if card.supported { + let path = card.path.to_string(); + let checked = selected_count > 0; + row = row.child( + div() + .id(SharedString::from(format!( + "diff-edit-file-checkbox-{}", + card.path + ))) + .font_family(fonts::mono()) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_size(px(11.)) + .text_color(rgb(if checked { t.selected_accent } else { t.fg_dim })) + .cursor_pointer() + .on_click(cx.listener(move |view, _, _, cx| view.toggle_diff_edit_file(&path, cx))) + .child(if checked { "[x]" } else { "[ ]" }), + ); + } + let (icon, color) = file_icon(card, t); + row = row.child(icons::icon(icon, 12., color)).child( + div() + .font_family(fonts::mono()) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_size(px(12.)) + .child(card.path.to_string()), + ); + if card.supported && card.changed_total > 0 { + let badge = if selected_count == card.changed_total { + "File".to_owned() + } else if selected_count == 0 { + "None".to_owned() + } else { + format!("{selected_count} / {} lines", card.changed_total) + }; + row = row.child( + div() + .px(px(6.)) + .rounded_full() + .bg(rgba(with_alpha(t.selected_accent, 0x24))) + .text_size(px(10.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(badge), + ); + } + let row = + row.child(div().flex_1()) + .child( + div() + .text_size(px(11.)) + .text_color(rgb(t.fg_dim)) + .child(if card.supported { + "Select files or lines to edit" + } else { + "Text edits not supported" + }), + ); + div() + .flex() + .w_full() + .h(px(ROW_HEIGHT)) + .px(px(18.)) + .child(row) + .into_any_element() +} + +fn placeholder_row(text: &'static str, t: &Theme) -> AnyElement { + div() + .flex() + .w_full() + .items_center() + .h(px(ROW_HEIGHT)) + .px(px(36.)) + .text_size(px(11.)) + .text_color(rgb(t.fg_dim)) + .child(text) + .into_any_element() +} + +fn file_icon(card: &DiffEditCardFile, t: &Theme) -> (&'static str, u32) { + let color = file_status::color_for_hunk_type(card.hunk_type, t); + match card.hunk_type { + jayjay_core::HunkType::Added => (glyph::PLUS_CIRCLE, color), + jayjay_core::HunkType::Removed => (glyph::MINUS_CIRCLE, color), + jayjay_core::HunkType::Modified => (glyph::PENCIL_CIRCLE, color), + jayjay_core::HunkType::Renamed => (glyph::ARROW_CIRCLE_RIGHT, color), + } +} diff --git a/shell/gpui/src/repo/window/diff_edit_gutter.rs b/shell/gpui/src/repo/window/diff_edit_gutter.rs new file mode 100644 index 00000000..e1922540 --- /dev/null +++ b/shell/gpui/src/repo/window/diff_edit_gutter.rs @@ -0,0 +1,97 @@ +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, Pixels, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, +}; +use jayjay_core::diff::DiffLine; + +use super::RepoWindow; +use crate::app::fonts; +use crate::app::theme::Theme; +use crate::diff::line::{ROW_HEIGHT, content_row, gutter_cell, line_bg_color}; + +const CHECKBOX_WIDTH: f32 = 18.; + +#[allow(clippy::too_many_arguments)] +pub(super) fn diff_edit_line_row( + path: &str, + line: &DiffLine, + display_line: u32, + editable: bool, + checked: bool, + t: &Theme, + advance: Pixels, + cx: &mut Context, +) -> AnyElement { + let bg = line_bg_color(line.style, line.conflict_kind, t); + let checkbox = if editable && line.is_changed() { + checkbox_cell(path, display_line, checked, bg, t, cx) + } else { + div() + .flex_none() + .w(px(CHECKBOX_WIDTH)) + .h(px(ROW_HEIGHT)) + .bg(rgb(bg)) + .into_any_element() + }; + let old_no = line.old_line_no.map(|n| n.to_string()).unwrap_or_default(); + let new_no = line.new_line_no.map(|n| n.to_string()).unwrap_or_default(); + div() + .flex() + .w_full() + .h(px(ROW_HEIGHT)) + .px(px(18.)) + .font_family(fonts::mono()) + .text_size(px(12.)) + .line_height(px(ROW_HEIGHT)) + .child( + div() + .flex() + .flex_none() + .border_r_1() + .border_color(rgb(t.border)) + .child(checkbox) + .child(gutter_cell(old_no, t, bg)) + .child(gutter_cell(new_no, t, bg)), + ) + .child( + div() + .flex_1() + .min_w_0() + .pl(px(4.)) + .child(content_row(line, t, None, None, advance)), + ) + .into_any_element() +} + +fn checkbox_cell( + path: &str, + display_line: u32, + checked: bool, + bg: u32, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let toggle_path = path.to_owned(); + div() + .id(SharedString::from(format!( + "diff-edit-line-{path}-{display_line}" + ))) + .flex_none() + .flex() + .items_center() + .justify_center() + .w(px(CHECKBOX_WIDTH)) + .h(px(ROW_HEIGHT)) + .bg(rgb(bg)) + .text_color(rgb(if checked { + t.selected_accent + } else { + t.fg_faint + })) + .cursor_pointer() + .on_click(cx.listener(move |view, _, _, cx| { + view.toggle_diff_edit_display_line(&toggle_path, display_line, cx); + })) + .child(if checked { "■" } else { "□" }) + .into_any_element() +} diff --git a/shell/gpui/src/repo/window/diff_edit_rows.rs b/shell/gpui/src/repo/window/diff_edit_rows.rs new file mode 100644 index 00000000..0e146e09 --- /dev/null +++ b/shell/gpui/src/repo/window/diff_edit_rows.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; + +use gpui::Context; +use jayjay_core::diff::FileDiff; +use jayjay_core::{DiffHunk, HunkType}; + +use super::RepoWindow; +use super::diff_edit_state::hunk_supports_diff_edit; + +pub(super) struct DiffEditRowModel { + pub(super) files: Vec, + pub(super) rows: Vec, + hunks: Arc>, + loaded_count: usize, + unsupported_count: usize, + diff_cache_count: usize, +} + +pub(super) struct DiffEditCardFile { + pub(super) path: Arc, + pub(super) hunk_type: HunkType, + pub(super) supported: bool, + pub(super) diff: Option>, + pub(super) changed_total: usize, +} + +pub(super) enum DiffEditRow { + Notice, + Gap, + HeaderPad { + top: bool, + }, + Header(usize), + Line { + file: usize, + line_ix: u32, + full_line: Option, + }, + Placeholder { + loading: bool, + }, +} + +impl RepoWindow { + pub(super) fn diff_edit_row_model(&mut self, cx: &Context) -> Arc { + let (hunks, diff_cache_count) = { + let vm = self.vm.read(cx); + (vm.files.clone().unwrap_or_default(), vm.diff_cache.len()) + }; + if let Some(model) = self.diff_edit.rows.as_ref() + && Arc::ptr_eq(&model.hunks, &hunks) + && model.loaded_count == self.diff_edit.loaded_files.len() + && model.unsupported_count == self.diff_edit.known_unsupported.len() + && model.diff_cache_count == diff_cache_count + { + return model.clone(); + } + let model = Arc::new(self.build_diff_edit_rows(hunks, diff_cache_count, cx)); + self.diff_edit.rows = Some(model.clone()); + model + } + + pub fn diff_edit_preview_line_count(&mut self, path: &str, cx: &Context) -> usize { + self.diff_edit_row_model(cx) + .files + .iter() + .find(|file| file.path.as_ref() == path) + .and_then(|file| file.diff.as_ref()) + .map(|diff| diff.lines.len()) + .unwrap_or(0) + } + + fn build_diff_edit_rows( + &self, + hunks: Arc>, + diff_cache_count: usize, + cx: &Context, + ) -> DiffEditRowModel { + let mut files = Vec::with_capacity(hunks.len()); + let mut rows = Vec::new(); + if self.diff_edit_has_known_unsupported(cx) { + rows.push(DiffEditRow::Notice); + } + for (file_ix, hunk) in hunks.iter().enumerate() { + if !rows.is_empty() { + rows.push(DiffEditRow::Gap); + } + let loaded = self.diff_edit.loaded_files.get(&hunk.path); + let supported = hunk_supports_diff_edit(hunk) && loaded.is_some(); + let preview = loaded.map(|file| file.display_diff.clone()).or_else(|| { + self.vm + .read(cx) + .diff_cache + .values() + .find(|cached| cached.diff.path == hunk.path) + .map(|cached| cached.diff.clone()) + }); + rows.push(DiffEditRow::HeaderPad { top: true }); + rows.push(DiffEditRow::Header(file_ix)); + rows.push(DiffEditRow::HeaderPad { top: false }); + match &preview { + Some(diff) if !diff.lines.is_empty() => { + let map = loaded.map(|file| file.display_to_full.clone()); + for line_ix in 0..diff.lines.len() as u32 { + let full_line = map + .as_ref() + .filter(|_| supported) + .and_then(|map| map.get(&(line_ix + 1)).copied()); + rows.push(DiffEditRow::Line { + file: file_ix, + line_ix, + full_line, + }); + } + } + Some(_) => rows.push(DiffEditRow::Placeholder { loading: false }), + None => rows.push(DiffEditRow::Placeholder { + loading: !self.diff_edit.known_unsupported.contains(&hunk.path), + }), + } + files.push(DiffEditCardFile { + path: hunk.path.as_str().into(), + hunk_type: hunk.hunk_type, + supported, + diff: preview, + changed_total: loaded.map(|file| file.changed.len()).unwrap_or(0), + }); + } + DiffEditRowModel { + files, + rows, + hunks, + loaded_count: self.diff_edit.loaded_files.len(), + unsupported_count: self.diff_edit.known_unsupported.len(), + diff_cache_count, + } + } +} diff --git a/shell/gpui/src/repo/window/diff_edit_state.rs b/shell/gpui/src/repo/window/diff_edit_state.rs new file mode 100644 index 00000000..6c08eebe --- /dev/null +++ b/shell/gpui/src/repo/window/diff_edit_state.rs @@ -0,0 +1,252 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use gpui::{AppContext, Context, UniformListScrollHandle}; +use jayjay_core::diff::{ + CollapsedDiff, DiffSpanStyle, FileDiff, collapse_context_with_mapping, compute_file_diff_full, +}; +use jayjay_core::placeholder::is_editable_text; +use jayjay_core::{DiffHunk, HunkType}; + +use crate::repo::view_model::DiffLoadState; +use crate::ui::scrollbar::ScrollbarBoundsSlot; + +use super::RepoWindow; +use super::diff_edit_rows::DiffEditRowModel; + +static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); + +pub(super) fn next_diff_edit_session() -> u64 { + NEXT_SESSION.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Clone)] +pub(super) struct DiffEditLoadedFile { + pub(super) old_content: Arc, + pub(super) new_content: Arc, + pub(super) display_diff: Arc, + pub(super) display_to_full: Arc>, + pub(super) changed: Arc>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffEditCheckboxState { + None, + Some, + All, +} + +pub struct DiffEditState { + pub active: bool, + pub selected: HashMap>, + pub(super) loaded_files: HashMap, + pub(super) loading: HashSet, + pub(super) known_unsupported: HashSet, + pub(super) select_all_pending: HashSet, + pub(super) change_id: Option, + pub(super) working_copy: bool, + pub(super) focus_pending: bool, + pub(super) session: u64, + pub(super) summary: (usize, usize), + pub(super) rows: Option>, + pub(super) message: String, + pub(super) scroll: UniformListScrollHandle, + pub(super) bounds: ScrollbarBoundsSlot, +} + +impl Default for DiffEditState { + fn default() -> Self { + Self { + active: false, + selected: HashMap::new(), + loaded_files: HashMap::new(), + loading: HashSet::new(), + known_unsupported: HashSet::new(), + select_all_pending: HashSet::new(), + change_id: None, + working_copy: false, + focus_pending: false, + session: 0, + summary: (0, 0), + rows: None, + message: String::new(), + scroll: UniformListScrollHandle::new(), + bounds: ScrollbarBoundsSlot::default(), + } + } +} + +pub(super) fn changed_lines(diff: &FileDiff) -> BTreeSet { + diff.lines + .iter() + .enumerate() + .filter(|(_, line)| matches!(line.style, DiffSpanStyle::Added | DiffSpanStyle::Removed)) + .map(|(ix, _)| ix as u32 + 1) + .collect() +} + +pub(super) fn checkbox_state( + selected: Option<&BTreeSet>, + all: &BTreeSet, +) -> DiffEditCheckboxState { + let count = selected + .map(|selected| selected.intersection(all).count()) + .unwrap_or(0); + if count == 0 { + DiffEditCheckboxState::None + } else if count == all.len() { + DiffEditCheckboxState::All + } else { + DiffEditCheckboxState::Some + } +} + +pub(super) fn hunk_supports_diff_edit(hunk: &DiffHunk) -> bool { + hunk.projection.is_none() && hunk.hunk_type != HunkType::Renamed +} + +impl RepoWindow { + /// Render and vm updates both drive this, so it must be a cheap no-op when nothing new arrived; the full-diff compute itself always runs off the main thread. + pub(super) fn ensure_diff_edit_files(&mut self, cx: &mut Context) { + if !self.diff_edit.active { + return; + } + let Some(hunks) = self.vm.read(cx).files.clone() else { + return; + }; + for hunk in hunks.iter() { + let path = hunk.path.clone(); + if self.diff_edit.loaded_files.contains_key(&path) + || self.diff_edit.loading.contains(&path) + || self.diff_edit.known_unsupported.contains(&path) + { + continue; + } + if !hunk_supports_diff_edit(hunk) { + self.mark_diff_edit_unsupported(path); + continue; + } + let (load_state, ignore_whitespace) = { + let vm = self.vm.read(cx); + (vm.diff_load_state(hunk), vm.ignore_whitespace) + }; + let cached = match load_state { + DiffLoadState::Missing => continue, + DiffLoadState::Failed => { + self.mark_diff_edit_unsupported(path); + continue; + } + DiffLoadState::Loaded(cached) => cached, + }; + let (Some(old), Some(new)) = (cached.old_content.clone(), cached.new_content.clone()) + else { + self.mark_diff_edit_unsupported(path); + continue; + }; + if !is_editable_text(&old) || !is_editable_text(&new) { + self.mark_diff_edit_unsupported(path); + continue; + } + let session = self.diff_edit.session; + self.diff_edit.loading.insert(path.clone()); + let compute_path = path.clone(); + let (task_old, task_new) = (old.clone(), new.clone()); + cx.spawn(async move |this, cx| { + let (full, collapsed) = cx + .background_spawn(async move { + let full = compute_file_diff_full( + &compute_path, + &task_old, + &task_new, + ignore_whitespace, + ); + let collapsed = collapse_context_with_mapping(&full); + (full, collapsed) + }) + .await; + let _ = this.update(cx, |view, cx| { + view.finish_diff_edit_load(session, path, old, new, full, collapsed, cx); + }); + }) + .detach(); + } + } + + #[allow(clippy::too_many_arguments)] + fn finish_diff_edit_load( + &mut self, + session: u64, + path: String, + old_content: Arc, + new_content: Arc, + full: FileDiff, + collapsed: CollapsedDiff, + cx: &mut Context, + ) { + if !self.diff_edit.active || self.diff_edit.session != session { + return; + } + self.diff_edit.loading.remove(&path); + let changed = Arc::new(changed_lines(&full)); + let loaded = DiffEditLoadedFile { + old_content, + new_content, + display_diff: Arc::new(collapsed.diff), + display_to_full: Arc::new( + collapsed + .display_to_full + .into_iter() + .map(|mapping| (mapping.display_line, mapping.full_line)) + .collect(), + ), + changed: changed.clone(), + }; + self.diff_edit.loaded_files.insert(path.clone(), loaded); + self.diff_edit.rows = None; + if self.diff_edit.select_all_pending.remove(&path) { + self.diff_edit.selected.insert(path, (*changed).clone()); + self.refresh_diff_edit_summary(); + } + cx.notify(); + } + + pub(super) fn mark_diff_edit_unsupported(&mut self, path: String) { + self.diff_edit.select_all_pending.remove(&path); + self.diff_edit.known_unsupported.insert(path); + self.diff_edit.rows = None; + } + + pub(super) fn refresh_diff_edit_summary(&mut self) { + let mut files = 0; + let mut lines = 0; + for (path, loaded) in &self.diff_edit.loaded_files { + let count = self + .diff_edit + .selected + .get(path) + .map(|selected| selected.intersection(&loaded.changed).count()) + .unwrap_or(0); + if count > 0 { + files += 1; + lines += count; + } + } + self.diff_edit.summary = (lines, files); + } + + pub fn diff_edit_file_supported(&self, hunk: &DiffHunk) -> bool { + hunk_supports_diff_edit(hunk) && self.diff_edit.loaded_files.contains_key(&hunk.path) + } + + pub fn diff_edit_has_known_unsupported(&self, cx: &Context) -> bool { + if !self.diff_edit.known_unsupported.is_empty() { + return true; + } + self.vm + .read(cx) + .files + .as_ref() + .is_some_and(|files| files.iter().any(|hunk| !hunk_supports_diff_edit(hunk))) + } +} diff --git a/shell/gpui/src/repo/window/diff_edit_view.rs b/shell/gpui/src/repo/window/diff_edit_view.rs new file mode 100644 index 00000000..e1d2507f --- /dev/null +++ b/shell/gpui/src/repo/window/diff_edit_view.rs @@ -0,0 +1,201 @@ +use gpui::{ + AnyElement, ClickEvent, Context, InteractiveElement, IntoElement, ParentElement, + StatefulInteractiveElement, Styled, div, px, rgb, +}; +use jayjay_core::DiffEditDestination; + +use super::RepoWindow; +use super::diff_edit_cards::diff_edit_body; +use crate::app::fonts; +use crate::app::theme::Theme; +use crate::ui::icons::{glyph, icon}; +use crate::ui::primitives::{button, divider_h}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiffEditSnapshot { + pub active: bool, + pub working_copy: bool, + pub description: String, + pub destinations: Vec, + pub selected_files: usize, + pub selected_lines: usize, +} + +pub(super) fn diff_edit_view( + view: &mut RepoWindow, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let header = header(view, t, cx); + let body = diff_edit_body(view, t, cx); + let action_bar = action_bar(view, t, cx); + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .child(header) + .child(divider_h(t)) + .child(body) + .child(action_bar) + .into_any_element() +} + +fn header(view: &RepoWindow, t: &Theme, cx: &mut Context) -> AnyElement { + let rev = view.diff_edit.change_id.as_deref().unwrap_or_default(); + let rev = rev.chars().take(12).collect::(); + let summary = view.diff_edit_selection_text(); + let selecting = view.diff_edit_selecting_all(); + let deselect = view.diff_edit_should_deselect(); + let toggle_label = if selecting { + "Selecting..." + } else if deselect { + "Deselect All" + } else { + "Select All" + }; + let mut toggle = button("diff-edit-select-all", toggle_label, t, false); + if !selecting { + toggle = toggle.on_click(cx.listener(|view, _, _, cx| view.toggle_diff_edit_all(cx))); + } + div() + .flex() + .items_center() + .gap(px(12.)) + .px(px(18.)) + .py(px(12.)) + .bg(rgb(t.header_bg)) + .child( + div() + .text_size(px(15.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Diff Edit"), + ) + .child( + div() + .font_family(fonts::mono()) + .text_size(px(12.)) + .text_color(rgb(t.fg_dim)) + .child(rev), + ) + .child(div().flex_1()) + .child( + div() + .text_size(px(11.)) + .text_color(rgb(t.fg_dim)) + .child(summary), + ) + .child(toggle) + .child( + button("diff-edit-cancel", "Cancel", t, false) + .debug_selector(|| "diff-edit-cancel".to_owned()) + .on_click(cx.listener(|view, _: &ClickEvent, _, cx| view.exit_diff_edit(cx))), + ) + .into_any_element() +} + +fn action_bar(view: &RepoWindow, t: &Theme, cx: &mut Context) -> AnyElement { + let working_copy = view.diff_edit.working_copy; + let summary = view.diff_edit_selection_text(); + let mut row = div() + .flex() + .items_center() + .gap(px(8.)) + .px(px(18.)) + .py(px(10.)) + .bg(rgb(t.header_bg)) + .child( + div() + .text_size(px(12.)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(summary), + ) + .child(div().flex_1()); + if !working_copy { + let description = view + .diff_edit + .message + .lines() + .next() + .filter(|line| !line.trim().is_empty()) + .unwrap_or("Add description...") + .to_owned(); + row = row + .child( + div() + .id("diff-edit-description") + .debug_selector(|| "diff-edit-description".to_owned()) + .w(px(260.)) + .h(px(28.)) + .px(px(7.)) + .flex() + .items_center() + .gap(px(6.)) + .overflow_hidden() + .whitespace_nowrap() + .rounded_sm() + .bg(rgb(t.toggle_inactive_bg)) + .text_color(rgb(t.toggle_inactive_fg)) + .text_size(px(11.)) + .cursor_pointer() + .hover(|s| s.bg(rgb(t.row_alt_bg))) + .on_click(cx.listener(|view, _, _, cx| { + view.open_diff_edit_description(cx); + })) + .child(icon(glyph::PENCIL_CIRCLE, 13., t.fg_dim)) + .child(description), + ) + .child(destination_button( + "diff-edit-child", + "Create New Child Change", + DiffEditDestination::NewChild, + true, + t, + cx, + )) + .child(destination_button( + "diff-edit-parallel", + "Create Parallel Change", + DiffEditDestination::NewParallel, + false, + t, + cx, + )) + .child(destination_button( + "diff-edit-move", + "Move to Working Copy", + DiffEditDestination::MoveToWorkingCopy, + false, + t, + cx, + )); + } + row = row.child(destination_button( + "diff-edit-done", + "Done", + DiffEditDestination::RemoveFromSource, + false, + t, + cx, + )); + div() + .flex() + .flex_col() + .child(divider_h(t)) + .child(row) + .into_any_element() +} + +fn destination_button( + id: &'static str, + label: &'static str, + destination: DiffEditDestination, + primary: bool, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + button(id, label, t, primary) + .on_click(cx.listener(move |view, _, _, cx| view.start_diff_edit_apply(destination, cx))) + .into_any_element() +} diff --git a/shell/gpui/src/repo/window/gutter_menu.rs b/shell/gpui/src/repo/window/gutter_menu.rs index 9ba58d3d..3c34d35a 100644 --- a/shell/gpui/src/repo/window/gutter_menu.rs +++ b/shell/gpui/src/repo/window/gutter_menu.rs @@ -54,6 +54,13 @@ impl RepoWindow { cx: &Context, ) -> Vec { let mut items = self.note_menu_items(hunk, line_ix, cx); + if self.can_enter_diff_edit(cx) { + items.push(ContextMenuItem::new( + "Open Diff Edit Mode", + glyph::PENCIL_CIRCLE, + ContextAction::OpenDiffEdit, + )); + } if let Some(item) = self.abandon_selected_lines_menu_item(hunk, cx) { items.push(item); } diff --git a/shell/gpui/src/repo/window/menu.rs b/shell/gpui/src/repo/window/menu.rs index b391b1a3..56b55f32 100644 --- a/shell/gpui/src/repo/window/menu.rs +++ b/shell/gpui/src/repo/window/menu.rs @@ -72,6 +72,9 @@ impl RepoWindow { ContextAction::CreateBookmark(rev) => { self.open_create_bookmark(rev.to_string(), cx); } + ContextAction::OpenStackedPr(rev) => { + self.open_stacked_pr(rev.to_string(), cx); + } ContextAction::MoveBookmarkToParent(name) => { self.move_bookmark_to_parent(name, cx); } @@ -170,6 +173,7 @@ impl RepoWindow { ContextAction::CreateWorkspace => { self.open_create_workspace(cx); } + ContextAction::OpenDiffEdit => self.enter_diff_edit(cx), ContextAction::AbandonSelectedLines(request) => { self.abandon_selected_diff_lines(request, cx); } @@ -229,7 +233,7 @@ impl RepoWindow { self.open_context_menu(anchor, items, cx); } - pub(super) fn build_change_menu(&self, change: &ChangeInfo, cx: &App) -> Vec { + pub fn build_change_menu(&self, change: &ChangeInfo, cx: &App) -> Vec { let rev = revset::change_revision(change); let mut items = vec![ ContextMenuItem::new( @@ -271,6 +275,11 @@ impl RepoWindow { )); } if !change.is_immutable { + items.push(ContextMenuItem::new( + "Stacked Pull Requests…", + glyph::GIT_BRANCH, + ContextAction::OpenStackedPr(rev.clone().into()), + )); let label = if change.is_divergent { "Abandon (resolve divergence)" } else { diff --git a/shell/gpui/src/repo/window/mod.rs b/shell/gpui/src/repo/window/mod.rs index db0df3c7..43863dd1 100644 --- a/shell/gpui/src/repo/window/mod.rs +++ b/shell/gpui/src/repo/window/mod.rs @@ -6,6 +6,13 @@ mod conflicts; mod dag; mod dag_row; mod detail; +mod diff_edit; +mod diff_edit_apply; +mod diff_edit_cards; +mod diff_edit_gutter; +mod diff_edit_rows; +mod diff_edit_state; +mod diff_edit_view; mod diff_rows; mod diff_select; mod drag; @@ -24,18 +31,28 @@ mod open; mod render; mod review; mod sidebar; +mod stacked_pr; +mod stacked_pr_ai; +mod stacked_pr_layers; +mod stacked_pr_render; +mod stacked_pr_results; +mod stacked_pr_snapshot; +mod stacked_pr_submit; mod status_bar; mod sync; mod view; mod workspace; pub use commit_ai::CommitMessageProvider; +pub use diff_edit_state::{DiffEditCheckboxState, DiffEditState}; +pub use diff_edit_view::DiffEditSnapshot; pub use file_actions::SplitFilesRequest; pub use file_actions_batch::FileBatchAction; pub use open::open_repo_window; pub use review::install_from_path as install_review_store_from_path; pub use review::install_in_memory as install_in_memory_review_store; pub use review::shared as shared_review_store; +pub use stacked_pr_snapshot::StackedPrSnapshot; pub use view::{ActivePane, PanelBoundsSlot, RepoWindow}; pub(crate) use gutter_menu::AbandonSelectedLinesRequest; diff --git a/shell/gpui/src/repo/window/render/mod.rs b/shell/gpui/src/repo/window/render/mod.rs index eb2795b8..7a97f249 100644 --- a/shell/gpui/src/repo/window/render/mod.rs +++ b/shell/gpui/src/repo/window/render/mod.rs @@ -8,6 +8,7 @@ use gpui::{ }; use super::detail::detail_pane; +use super::diff_edit_view::diff_edit_view; use super::onboarding::onboarding_pane; use super::sidebar::sidebar; use super::status_bar::status_bar; @@ -30,6 +31,7 @@ use repo_init::{repo_init_error_pane, repo_loading_pane}; impl Render for RepoWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.sync_diff_edit_change(cx); // Cheap unless a note-affecting write happened (a single `stat` + small `Vec` compare); see `sync_review_notes`'s docs for why this can't just be a `mutate()`-only refresh. self.sync_review_notes(cx); let t = theme(cx).clone(); @@ -138,7 +140,15 @@ impl Render for RepoWindow { .on_action(cx.listener(|view, _: &SaveNoteComposer, _, cx| { view.submit_text_modal(cx); })) + .on_action(cx.listener( + |view, _: &crate::app::actions::SubmitStackedPr, _, cx| { + view.submit_stacked_pr(cx); + }, + )) .on_key_down(cx.listener(|view, ev: &gpui::KeyDownEvent, window, cx| { + if view.handle_stacked_pr_key(ev, cx) { + return; + } if view.is_text_input_focused(window, cx) { return; } @@ -165,6 +175,14 @@ impl Render for RepoWindow { .bg(rgb(t.detail_bg)) .text_color(rgb(t.fg)); + if let Some(state) = self.stacked_pr.as_ref() { + root = root.key_context(if state.active_input.is_some() { + "StackedPrPanel StackedPrInput" + } else { + "StackedPrPanel" + }); + } + root = append_menu_bar(root, &t, cx); if let Some(onboarding) = self.onboarding.as_ref() { @@ -197,6 +215,27 @@ impl Render for RepoWindow { return root.into_any_element(); } + let content = if self.diff_edit_active() { + div() + .flex() + .flex_row() + .flex_1() + .min_h_0() + .child(sidebar(self, &t, sidebar_width, cx)) + .child(resize_handle(DragTarget::Sidebar, &t, cx)) + .child(diff_edit_view(self, &t, cx)) + } else { + div() + .flex() + .flex_row() + .flex_1() + .min_h_0() + .child(sidebar(self, &t, sidebar_width, cx)) + .child(resize_handle(DragTarget::Sidebar, &t, cx)) + .child(file_column_wrapper(self, file_column_width, cx)) + .child(resize_handle(DragTarget::FileColumn, &t, cx)) + .child(detail_pane(self, &t, window, cx)) + }; root = root .child(crate::repo::toolbar::toolbar( repo_path, @@ -205,18 +244,7 @@ impl Render for RepoWindow { is_refreshing, cx, )) - .child( - div() - .flex() - .flex_row() - .flex_1() - .min_h_0() - .child(sidebar(self, &t, sidebar_width, cx)) - .child(resize_handle(DragTarget::Sidebar, &t, cx)) - .child(file_column_wrapper(self, file_column_width, cx)) - .child(resize_handle(DragTarget::FileColumn, &t, cx)) - .child(detail_pane(self, &t, window, cx)), - ) + .child(content) .child(status_bar(self, &t, cx)); if let Some(menu) = context_menu_overlay { @@ -225,6 +253,10 @@ impl Render for RepoWindow { if let Some(menu) = app_menu_overlay { root = root.child(menu); } + if self.diff_edit.active && self.diff_edit.focus_pending { + self.diff_edit.focus_pending = false; + window.focus(&self.focus_handle, cx); + } if self.text_modal.as_ref().is_some_and(|m| m.focus_pending) { let handle = self .text_modal @@ -241,6 +273,11 @@ impl Render for RepoWindow { if let Some(modal) = self.text_modal.as_ref() { root = root.child(text_modal_overlay(modal, &t, cx)); } + if let Some(stacked_pr) = self.stacked_pr.as_ref() { + root = root.child(super::stacked_pr_render::stacked_pr_overlay( + stacked_pr, &t, cx, + )); + } if let Some(message) = self.feedback.toast.clone() { root = root.child(toast_overlay(message)); } @@ -262,6 +299,8 @@ impl RepoWindow { vm.clear_error(); cx.notify(); }); + } else if self.stacked_pr.is_some() { + self.close_stacked_pr(cx); } else if self.text_modal.is_some() { self.close_text_modal(cx); } else if self.context_menu.is_some() { @@ -270,6 +309,8 @@ impl RepoWindow { self.close_app_menu(cx); } else if self.find.query.is_some() { self.close_find(cx); + } else if self.diff_edit.active { + self.exit_diff_edit(cx); } else { return false; } diff --git a/shell/gpui/src/repo/window/stacked_pr.rs b/shell/gpui/src/repo/window/stacked_pr.rs new file mode 100644 index 00000000..cb4a82f4 --- /dev/null +++ b/shell/gpui/src/repo/window/stacked_pr.rs @@ -0,0 +1,249 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use gpui::{AppContext, Context, KeyDownEvent}; +use jayjay_core::{Stack, StackedPrResult, SubmitStackLayer, is_valid_bookmark_name}; + +use super::RepoWindow; +use crate::repo::StackedPrProvider; +use crate::ui::input::LineInput; + +static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); + +pub(super) fn next_stacked_pr_generation() -> u64 { + NEXT_GENERATION.fetch_add(1, Ordering::Relaxed) +} + +pub(crate) struct StackedPrState { + pub(crate) tip_rev: String, + pub(crate) phase: StackedPrPhase, + pub(crate) inputs: Vec, + pub(crate) active_input: Option, + pub(crate) generation: u64, + pub(crate) ai_generation: u64, + pub(crate) ai_in_flight: bool, + pub(super) provider: Arc, +} + +pub(crate) enum StackedPrPhase { + Loading, + Preview(Stack), + Submitting(Stack), + Results(StackedPrResult), + Error(String), +} + +impl StackedPrState { + fn new(tip_rev: String, provider: Arc) -> Self { + Self { + tip_rev, + phase: StackedPrPhase::Loading, + inputs: Vec::new(), + active_input: None, + generation: next_stacked_pr_generation(), + ai_generation: next_stacked_pr_generation(), + ai_in_flight: false, + provider, + } + } + + pub(crate) fn stack(&self) -> Option<&Stack> { + match &self.phase { + StackedPrPhase::Preview(stack) | StackedPrPhase::Submitting(stack) => Some(stack), + _ => None, + } + } + + pub(crate) fn warning(&self, index: usize) -> Option<&'static str> { + let name = self.inputs.get(index)?.text(); + if name.is_empty() || !is_valid_bookmark_name(name) { + return Some("Not a valid bookmark name"); + } + if self + .inputs + .iter() + .enumerate() + .any(|(other, input)| other != index && input.text() == name) + { + return Some("Duplicate bookmark name"); + } + None + } + + pub(crate) fn can_submit(&self) -> bool { + matches!(self.phase, StackedPrPhase::Preview(_)) + && !self.ai_in_flight + && !self.inputs.is_empty() + && (0..self.inputs.len()).all(|index| self.warning(index).is_none()) + } + + pub(super) fn payload(&self) -> Option> { + let stack = self.stack()?; + Some( + stack + .layers + .iter() + .zip(&self.inputs) + .map(|(layer, input)| SubmitStackLayer { + change_id: layer.change_id.clone(), + bookmark: input.text().to_owned(), + title: layer.title.clone(), + body: layer.body.clone(), + }) + .collect(), + ) + } +} + +impl RepoWindow { + fn stacked_pr_active_input(view: &mut Self) -> Option<&mut LineInput> { + let state = view.stacked_pr.as_mut()?; + state.inputs.get_mut(state.active_input?) + } + + pub(crate) fn activate_stacked_pr_input(&mut self, index: usize, cx: &mut Context) { + let Some(state) = self.stacked_pr.as_mut() else { + return; + }; + if !matches!(state.phase, StackedPrPhase::Preview(_)) || index >= state.inputs.len() { + return; + } + LineInput::hide_for_owner(self, cx, Self::stacked_pr_active_input); + self.stacked_pr.as_mut().unwrap().active_input = Some(index); + LineInput::show_for_owner(self, cx, Self::stacked_pr_active_input); + cx.notify(); + } + + pub(crate) fn deactivate_stacked_pr_input(&mut self, cx: &mut Context) { + if self.stacked_pr.is_none() { + return; + } + LineInput::hide_for_owner(self, cx, Self::stacked_pr_active_input); + if let Some(state) = self.stacked_pr.as_mut() { + state.active_input = None; + } + cx.notify(); + } + + pub fn set_stacked_pr_provider(&mut self, provider: Arc) { + self.stacked_pr_provider = provider; + } + + pub fn open_stacked_pr(&mut self, tip_rev: String, cx: &mut Context) { + let provider = self.stacked_pr_provider.clone(); + self.stacked_pr = Some(StackedPrState::new(tip_rev, provider)); + self.start_stacked_pr_detection(cx); + } + + pub fn retry_stacked_pr(&mut self, cx: &mut Context) { + let Some(state) = self.stacked_pr.as_mut() else { + return; + }; + state.generation = next_stacked_pr_generation(); + state.phase = StackedPrPhase::Loading; + state.inputs.clear(); + state.active_input = None; + state.ai_generation = next_stacked_pr_generation(); + state.ai_in_flight = false; + self.start_stacked_pr_detection(cx); + } + + fn start_stacked_pr_detection(&mut self, cx: &mut Context) { + let Some(state) = self.stacked_pr.as_ref() else { + return; + }; + let Some(repo) = self.vm.read(cx).repo.clone() else { + self.finish_stacked_pr_detection( + state.generation, + Err(jayjay_core::Error::internal("repository is not open")), + cx, + ); + return; + }; + let generation = state.generation; + let tip_rev = state.tip_rev.clone(); + let provider = state.provider.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { provider.detect(&repo, "trunk()", &tip_rev) }) + .await; + let _ = this.update(cx, |view, cx| { + view.finish_stacked_pr_detection(generation, result, cx); + }); + }) + .detach(); + cx.notify(); + } + + fn finish_stacked_pr_detection( + &mut self, + generation: u64, + result: jayjay_core::CoreResult, + cx: &mut Context, + ) { + let Some(state) = self.stacked_pr.as_mut() else { + return; + }; + if state.generation != generation { + return; + } + match result { + Ok(stack) => { + state.inputs = stack + .layers + .iter() + .map(|layer| LineInput::new(&layer.bookmark)) + .collect(); + state.phase = StackedPrPhase::Preview(stack); + } + Err(error) => state.phase = StackedPrPhase::Error(error.to_string()), + } + cx.notify(); + } + + pub fn edit_stacked_pr_name( + &mut self, + index: usize, + name: impl Into, + cx: &mut Context, + ) { + let Some(state) = self.stacked_pr.as_mut() else { + return; + }; + if matches!(state.phase, StackedPrPhase::Preview(_)) + && let Some(input) = state.inputs.get_mut(index) + { + input.set_text(name); + state.active_input = Some(index); + cx.notify(); + } + } + + pub(super) fn handle_stacked_pr_key( + &mut self, + event: &KeyDownEvent, + cx: &mut Context, + ) -> bool { + let Some(state) = self.stacked_pr.as_mut() else { + return false; + }; + if event.keystroke.key == "escape" { + self.close_stacked_pr(cx); + return true; + } + if event.keystroke.key == "enter" && state.active_input.is_some() { + self.deactivate_stacked_pr_input(cx); + return true; + } + let Some(index) = state.active_input else { + return true; + }; + let Some(input) = state.inputs.get_mut(index) else { + return true; + }; + if input.handle_key(event, cx).changed { + cx.notify(); + } + true + } +} diff --git a/shell/gpui/src/repo/window/stacked_pr_ai.rs b/shell/gpui/src/repo/window/stacked_pr_ai.rs new file mode 100644 index 00000000..ce2689e0 --- /dev/null +++ b/shell/gpui/src/repo/window/stacked_pr_ai.rs @@ -0,0 +1,98 @@ +use gpui::{AppContext, Context}; + +use super::RepoWindow; +use super::stacked_pr::{StackedPrPhase, next_stacked_pr_generation}; + +impl RepoWindow { + pub fn generate_stacked_pr_names(&mut self, cx: &mut Context) { + let Some(state) = self.stacked_pr.as_mut() else { + return; + }; + if state.ai_in_flight || !matches!(state.phase, StackedPrPhase::Preview(_)) { + return; + } + if self.commit_ai.provider_name.is_none() { + self.show_toast( + "No AI CLI found. Install codex or claude to generate names.", + cx, + ); + return; + } + let stack = state.stack().unwrap(); + let requests: Vec<_> = stack + .layers + .iter() + .enumerate() + .filter(|(_, layer)| !layer.bookmark_existed) + .map(|(index, layer)| { + ( + index, + [layer.title.as_str(), layer.body.as_str()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"), + layer.change_id_short.clone(), + ) + }) + .collect(); + if requests.is_empty() { + return; + } + let snapshot: Vec<_> = state + .inputs + .iter() + .map(|input| input.text().to_owned()) + .collect(); + state.ai_generation = next_stacked_pr_generation(); + let generation = state.ai_generation; + state.ai_in_flight = true; + let provider = self.commit_ai.provider.clone(); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + requests + .into_iter() + .map(|(index, description, suffix)| { + provider + .generate_branch_name(&description) + .map(|slug| (index, format!("{slug}-{suffix}"))) + }) + .collect::, _>>() + }) + .await; + let _ = this.update(cx, |view, cx| { + view.finish_stacked_pr_naming(generation, snapshot, result, cx); + }); + }) + .detach(); + cx.notify(); + } + + fn finish_stacked_pr_naming( + &mut self, + generation: u64, + snapshot: Vec, + result: Result, String>, + cx: &mut Context, + ) { + let Some(state) = self.stacked_pr.as_mut() else { + return; + }; + if generation != state.ai_generation { + return; + } + state.ai_in_flight = false; + match result { + Ok(names) => { + for (index, name) in names { + if state.inputs[index].text() == snapshot[index] { + state.inputs[index].set_text(name); + } + } + cx.notify(); + } + Err(message) => self.show_toast(message, cx), + } + } +} diff --git a/shell/gpui/src/repo/window/stacked_pr_layers.rs b/shell/gpui/src/repo/window/stacked_pr_layers.rs new file mode 100644 index 00000000..3ec02d93 --- /dev/null +++ b/shell/gpui/src/repo/window/stacked_pr_layers.rs @@ -0,0 +1,191 @@ +use super::RepoWindow; +use super::stacked_pr::{StackedPrPhase, StackedPrState}; +use crate::app::fonts; +use crate::app::theme::{Theme, with_alpha}; +use crate::ui::icons::{glyph, icon}; +use crate::ui::input::line_input_content; +use gpui::{ + AnyElement, ClickEvent, Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, rgba, +}; +use jayjay_core::{Stack, StackLayer}; + +pub(super) fn layer_list( + state: &StackedPrState, + stack: &Stack, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let mut list = div() + .id("stacked-pr-layers") + .flex() + .flex_col() + .gap(px(6.)) + .max_h(px(280.)) + .overflow_y_scroll(); + // Top of the stack first (core layers are bottom→top), matching the SwiftUI panel. + for (index, layer) in stack.layers.iter().enumerate().rev() { + list = list.child(layer_card(state, stack, index, layer, t, cx)); + } + list.into_any_element() +} + +fn layer_card( + state: &StackedPrState, + stack: &Stack, + index: usize, + layer: &StackLayer, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let base = if index == 0 { + stack.base_bookmark.as_str() + } else { + state.inputs[index - 1].text() + }; + let mut base_row = div() + .flex() + .items_center() + .gap(px(5.)) + .font_family(fonts::mono()) + .text_size(px(10.)) + .text_color(rgb(t.fg_faint)) + .child(icon(glyph::ARROW_RIGHT, 8., t.fg_faint)) + .child(SharedString::from(base.to_owned())); + if !layer.bookmark_existed { + base_row = base_row.child( + div() + .px(px(4.)) + .rounded_full() + .bg(rgba(with_alpha(t.file_added_color, 0x2e))) + .text_size(px(9.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(t.file_added_color)) + .child("new"), + ); + } + let mut column = div().flex().flex_col().flex_1().min_w_0().gap(px(4.)); + if !layer.title.is_empty() { + column = column.child( + div() + .truncate() + .text_size(px(12.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(SharedString::from(layer.title.clone())), + ); + } + column = column + .child(bookmark_field(state, index, t, cx)) + .child(base_row); + div() + .id(("stacked-pr-layer", index)) + .debug_selector(move || format!("stacked-pr-layer-{index}")) + .flex() + .items_start() + .gap(px(10.)) + .px(px(10.)) + .py(px(7.)) + .rounded_lg() + .bg(rgba(with_alpha(t.fg, if t.is_dark { 0x10 } else { 0x0a }))) + .child(icon(glyph::GIT_BRANCH, 11., t.fg_faint)) + .child(column) + .into_any_element() +} + +fn bookmark_field( + state: &StackedPrState, + index: usize, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let editing = state.active_input == Some(index); + let editable = matches!(state.phase, StackedPrPhase::Preview(_)); + let warning = state.warning(index); + let warning_icon = warning.map(|message| { + div() + .id(("stacked-pr-warning", index)) + .flex_none() + .child(icon(glyph::WARNING, 10., t.tag_modified_fg)) + .child( + div() + .text_size(px(10.)) + .text_color(rgb(t.tag_modified_fg)) + .child(message), + ) + .flex() + .items_center() + .gap(px(4.)) + }); + if editing { + let input = line_input_content( + &state.inputs[index], + "bookmark name", + t, + Some("stacked-pr-caret"), + ); + return div() + .flex() + .items_center() + .gap(px(5.)) + .child( + div() + .id(("stacked-pr-input", index)) + .debug_selector(move || format!("stacked-pr-input-{index}")) + .flex() + .flex_1() + .min_w_0() + .h(px(24.)) + .items_center() + .overflow_hidden() + .whitespace_nowrap() + .px(px(7.)) + .rounded_sm() + .border_1() + .border_color(rgb(if warning.is_some() { + t.tag_modified_fg + } else { + t.border + })) + .font_family(fonts::mono()) + .text_size(px(11.)) + .cursor_text() + .child(input), + ) + .children(warning_icon) + .child( + div() + .id(("stacked-pr-done", index)) + .cursor_pointer() + .child(icon(glyph::CHECK, 12., t.file_added_color)) + .on_click(cx.listener(|view, _: &ClickEvent, _, cx| { + view.deactivate_stacked_pr_input(cx) + })), + ) + .into_any_element(); + } + let mut row = div() + .id(("stacked-pr-input", index)) + .debug_selector(move || format!("stacked-pr-input-{index}")) + .flex() + .items_center() + .gap(px(5.)) + .font_family(fonts::mono()) + .text_size(px(11.)) + .text_color(rgb(t.fg_dim)) + .child( + div() + .min_w_0() + .truncate() + .child(SharedString::from(state.inputs[index].text().to_owned())), + ) + .children(warning_icon) + .child(icon(glyph::PENCIL_CIRCLE, 10., t.fg_faint)); + if editable { + row = row + .cursor_text() + .on_click(cx.listener(move |view, _: &ClickEvent, _, cx| { + view.activate_stacked_pr_input(index, cx) + })); + } + row.into_any_element() +} diff --git a/shell/gpui/src/repo/window/stacked_pr_render.rs b/shell/gpui/src/repo/window/stacked_pr_render.rs new file mode 100644 index 00000000..f441ca44 --- /dev/null +++ b/shell/gpui/src/repo/window/stacked_pr_render.rs @@ -0,0 +1,179 @@ +use super::RepoWindow; +use super::stacked_pr::{StackedPrPhase, StackedPrState}; +use super::stacked_pr_layers::layer_list; +use super::stacked_pr_results::{centered_message, error_body, results_body}; +use crate::app::theme::Theme; +use crate::ui::icons::{glyph, icon}; +use crate::ui::primitives::{button, icon_label}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, + StatefulInteractiveElement, Styled, div, px, rgb, rgba, +}; +use jayjay_core::Stack; + +pub(super) fn stacked_pr_overlay( + state: &StackedPrState, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let busy = state.ai_in_flight + || matches!( + state.phase, + StackedPrPhase::Loading | StackedPrPhase::Submitting(_) + ); + let title = if matches!(state.phase, StackedPrPhase::Results(_)) { + "Stacked PRs Submitted" + } else { + "Stacked Pull Requests" + }; + let panel = div() + .key_context(if state.active_input.is_some() { + "StackedPrPanel StackedPrInput" + } else { + "StackedPrPanel" + }) + .flex() + .flex_col() + .gap(px(14.)) + .w(px(480.)) + .max_w_full() + .p(px(20.)) + .rounded_lg() + .border_1() + .border_color(rgb(t.border)) + .bg(rgb(t.header_bg)) + .child( + div() + .flex() + .items_center() + .gap(px(8.)) + .child( + icon_label(glyph::GIT_BRANCH, title, 16., t.toggle_active_fg) + .text_size(px(15.)) + .font_weight(gpui::FontWeight::SEMIBOLD), + ) + .when(busy, |row| { + row.child( + div() + .text_size(px(11.)) + .text_color(rgb(t.fg_dim)) + .child("Working…"), + ) + }), + ) + .child(phase_body(state, t, cx)); + + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .flex() + .items_center() + .justify_center() + .bg(rgba(0x00000033)) + .occlude() + .child(panel) + .into_any_element() +} + +fn phase_body(state: &StackedPrState, t: &Theme, cx: &mut Context) -> AnyElement { + match &state.phase { + StackedPrPhase::Loading => centered_message("Detecting changes above trunk()…", t), + StackedPrPhase::Preview(stack) | StackedPrPhase::Submitting(stack) => { + let submitting = matches!(state.phase, StackedPrPhase::Submitting(_)); + div() + .flex() + .flex_col() + .gap(px(12.)) + .child(subtitle_row(state, stack, submitting, t, cx)) + .child(layer_list(state, stack, t, cx)) + .child(action_row(state, submitting, t, cx)) + .into_any_element() + } + StackedPrPhase::Results(result) => results_body(result, t, cx), + StackedPrPhase::Error(error) => error_body(error, t, cx), + } +} + +fn subtitle_row( + state: &StackedPrState, + stack: &Stack, + submitting: bool, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let count = stack.layers.len(); + let busy = submitting || state.ai_in_flight; + let label = if state.ai_in_flight { + "Generating…" + } else { + "Generate bookmarks" + }; + let mut generate = button("stacked-pr-ai-name", label, t, false) + .text_size(px(11.)) + .opacity(if busy { 0.45 } else { 1. }); + if !busy { + generate = + generate.on_click(cx.listener(|view, _, _, cx| view.generate_stacked_pr_names(cx))); + } + div() + .flex() + .items_center() + .gap(px(8.)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(12.)) + .text_color(rgb(t.fg_dim)) + .child(format!( + "{count} change{} — one PR each, bottom targets {}.", + if count == 1 { "" } else { "s" }, + stack.base_bookmark + )), + ) + .child( + div() + .flex() + .items_center() + .gap(px(4.)) + .child(icon(glyph::SPARKLE, 11., t.fg_dim)) + .child(generate), + ) + .into_any_element() +} + +fn action_row( + state: &StackedPrState, + submitting: bool, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let busy = submitting || state.ai_in_flight; + let enabled = state.can_submit(); + let submit = if enabled { + button("stacked-pr-submit", "Submit", t, true) + .on_click(cx.listener(|view, _, _, cx| view.submit_stacked_pr(cx))) + .into_any_element() + } else { + button("stacked-pr-submit", "Submit", t, false) + .opacity(0.45) + .into_any_element() + }; + div() + .flex() + .justify_center() + .gap(px(12.)) + .child( + button("stacked-pr-cancel", "Cancel", t, false) + .when(!busy, |button| { + button.on_click(cx.listener(|view, _, _, cx| view.close_stacked_pr(cx))) + }) + .opacity(if busy { 0.45 } else { 1. }), + ) + .child(submit) + .into_any_element() +} diff --git a/shell/gpui/src/repo/window/stacked_pr_results.rs b/shell/gpui/src/repo/window/stacked_pr_results.rs new file mode 100644 index 00000000..fecc14e9 --- /dev/null +++ b/shell/gpui/src/repo/window/stacked_pr_results.rs @@ -0,0 +1,146 @@ +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, rgba, +}; +use jayjay_core::StackLayerOutcome; + +use super::RepoWindow; +use crate::app::fonts; +use crate::app::theme::Theme; +use crate::ui::primitives::button; + +pub(super) fn results_body( + result: &jayjay_core::StackedPrResult, + t: &Theme, + cx: &mut Context, +) -> AnyElement { + let mut list = div() + .id("stacked-pr-results") + .flex() + .flex_col() + .gap(px(6.)) + .max_h(px(300.)) + .overflow_y_scroll(); + for (index, layer) in result.layers.iter().enumerate() { + let (label, color) = match layer.outcome { + StackLayerOutcome::Created => ("Created", t.success_fg), + StackLayerOutcome::Updated => ("Updated", t.toggle_active_fg), + StackLayerOutcome::Failed => ("Failed", t.error_fg), + }; + let mut title = div() + .flex() + .items_center() + .gap(px(7.)) + .child( + div() + .px(px(5.)) + .py(px(2.)) + .rounded_sm() + .bg(rgba((color << 8) | 0x22)) + .text_size(px(9.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(color)) + .child(label), + ) + .child(if layer.title.is_empty() { + layer.bookmark.clone() + } else { + layer.title.clone() + }); + if layer.pr_number > 0 && !layer.pr_url.is_empty() { + let url = layer.pr_url.clone(); + title = title.child( + div() + .id(("stacked-pr-url", index)) + .debug_selector(move || format!("stacked-pr-url-{index}")) + .cursor_pointer() + .text_color(rgb(t.toggle_active_fg)) + .child(format!("#{}", layer.pr_number)) + .on_click(move |_, _, cx| cx.open_url(&url)), + ); + } + list = list.child( + div() + .id(("stacked-pr-result", index)) + .debug_selector(move || format!("stacked-pr-result-{index}")) + .flex() + .flex_col() + .gap(px(4.)) + .px(px(10.)) + .py(px(8.)) + .rounded_md() + .bg(rgb(t.row_alt_bg)) + .text_size(px(12.)) + .child(title) + .child( + div() + .font_family(fonts::mono()) + .text_size(px(10.)) + .text_color(rgb(t.fg_dim)) + .child(SharedString::from(layer.detail.clone())), + ), + ); + } + div() + .flex() + .flex_col() + .gap(px(10.)) + .child(list) + .child( + div() + .text_size(px(11.)) + .text_color(rgb(t.fg_dim)) + .child(SharedString::from(result.message.clone())), + ) + .child( + div().flex().justify_end().child( + button("stacked-pr-done", "Done", t, true) + .on_click(cx.listener(|view, _, _, cx| view.close_stacked_pr(cx))), + ), + ) + .into_any_element() +} + +pub(super) fn error_body(error: &str, t: &Theme, cx: &mut Context) -> AnyElement { + div() + .flex() + .flex_col() + .gap(px(12.)) + .child( + div() + .px(px(9.)) + .py(px(8.)) + .rounded_md() + .bg(rgba((t.error_fg << 8) | 0x18)) + .text_size(px(11.)) + .text_color(rgb(t.error_fg)) + .child(SharedString::from(error.to_owned())), + ) + .child( + div() + .flex() + .justify_end() + .gap(px(8.)) + .child( + button("stacked-pr-close", "Close", t, false) + .on_click(cx.listener(|view, _, _, cx| view.close_stacked_pr(cx))), + ) + .child( + button("stacked-pr-retry", "Retry", t, true) + .on_click(cx.listener(|view, _, _, cx| view.retry_stacked_pr(cx))), + ), + ) + .into_any_element() +} + +pub(super) fn centered_message(message: &'static str, t: &Theme) -> AnyElement { + div() + .h(px(80.)) + .flex() + .items_center() + .justify_center() + .text_size(px(12.)) + .text_color(rgb(t.fg_dim)) + .child(message) + .into_any_element() +} diff --git a/shell/gpui/src/repo/window/stacked_pr_snapshot.rs b/shell/gpui/src/repo/window/stacked_pr_snapshot.rs new file mode 100644 index 00000000..23d9951d --- /dev/null +++ b/shell/gpui/src/repo/window/stacked_pr_snapshot.rs @@ -0,0 +1,66 @@ +use super::RepoWindow; +use super::stacked_pr::StackedPrPhase; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StackedPrSnapshot { + Closed, + Loading, + Preview { + names: Vec, + bases: Vec, + warnings: Vec>, + can_submit: bool, + ai_in_flight: bool, + }, + Submitting, + Results { + outcomes: Vec, + message: String, + }, + Error(String), +} + +impl RepoWindow { + pub fn stacked_pr_snapshot(&self) -> StackedPrSnapshot { + let Some(state) = self.stacked_pr.as_ref() else { + return StackedPrSnapshot::Closed; + }; + match &state.phase { + StackedPrPhase::Loading => StackedPrSnapshot::Loading, + StackedPrPhase::Preview(stack) => StackedPrSnapshot::Preview { + names: state + .inputs + .iter() + .map(|input| input.text().to_owned()) + .collect(), + bases: stack + .layers + .iter() + .enumerate() + .map(|(index, _)| { + if index == 0 { + stack.base_bookmark.clone() + } else { + state.inputs[index - 1].text().to_owned() + } + }) + .collect(), + warnings: (0..state.inputs.len()) + .map(|index| state.warning(index).map(str::to_owned)) + .collect(), + can_submit: state.can_submit(), + ai_in_flight: state.ai_in_flight, + }, + StackedPrPhase::Submitting(_) => StackedPrSnapshot::Submitting, + StackedPrPhase::Results(result) => StackedPrSnapshot::Results { + outcomes: result + .layers + .iter() + .map(|layer| format!("{:?}", layer.outcome)) + .collect(), + message: result.message.clone(), + }, + StackedPrPhase::Error(error) => StackedPrSnapshot::Error(error.clone()), + } + } +} diff --git a/shell/gpui/src/repo/window/stacked_pr_submit.rs b/shell/gpui/src/repo/window/stacked_pr_submit.rs new file mode 100644 index 00000000..b8853294 --- /dev/null +++ b/shell/gpui/src/repo/window/stacked_pr_submit.rs @@ -0,0 +1,61 @@ +use gpui::Context; + +use super::RepoWindow; +use super::stacked_pr::{StackedPrPhase, next_stacked_pr_generation}; + +impl RepoWindow { + pub fn submit_stacked_pr(&mut self, cx: &mut Context) { + let Some(state) = self.stacked_pr.as_mut() else { + return; + }; + if !state.can_submit() { + return; + } + let Some(payload) = state.payload() else { + return; + }; + let stack = match &state.phase { + StackedPrPhase::Preview(stack) => stack.clone(), + _ => return, + }; + state.generation = next_stacked_pr_generation(); + let generation = state.generation; + let provider = state.provider.clone(); + state.active_input = None; + state.phase = StackedPrPhase::Submitting(stack); + let task = self + .vm + .update(cx, |vm, cx| vm.submit_stack(provider, payload, cx)); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |view, cx| { + let Some(state) = view.stacked_pr.as_mut() else { + return; + }; + if state.generation != generation { + return; + } + state.phase = match result { + Ok(result) => StackedPrPhase::Results(result), + Err(error) => StackedPrPhase::Error(error.to_string()), + }; + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + pub fn close_stacked_pr(&mut self, cx: &mut Context) { + let submitting = self + .stacked_pr + .as_ref() + .is_some_and(|state| matches!(state.phase, StackedPrPhase::Submitting(_))); + if submitting { + return; + } + if self.stacked_pr.take().is_some() { + cx.notify(); + } + } +} diff --git a/shell/gpui/src/repo/window/view.rs b/shell/gpui/src/repo/window/view.rs index f5e98ad3..800fcea4 100644 --- a/shell/gpui/src/repo/window/view.rs +++ b/shell/gpui/src/repo/window/view.rs @@ -17,8 +17,10 @@ use crate::ui::context_menu::ContextMenuState; use crate::ui::input::LineInput; use crate::ui::text_area::TextArea; +use super::DiffEditState; use super::commit_ai::CommitAiState; use super::onboarding::OnboardingState; +use super::stacked_pr::StackedPrState; // Written by a canvas overlay during prepaint, read by mouse handlers. pub type PanelBoundsSlot = Rc>>>; @@ -31,6 +33,7 @@ pub struct RepoWindow { pub(crate) file_column: FileColumnUiState, pub(crate) find: FindState, pub(crate) diff: DiffPanelState, + pub(crate) diff_edit: DiffEditState, pub(crate) scrolls: ScrollHandles, pub(crate) feedback: FeedbackState, pub(crate) collapsed_dirs: std::collections::HashSet, @@ -42,6 +45,8 @@ pub struct RepoWindow { pub(crate) description_input: Entity