diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 29f4d58a9..d4adf0741 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -583,12 +583,15 @@ pub async fn vcs_create_branch( name: String, start: Option, checkout: bool, + overwrite: Option, ) -> IdeResult<()> { host.require_trust(window)?; let project = host.project(window)?; let recorded = checkout.then_some("Git checkout"); blocking(move || { - project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout)) + project.with_repo_recorded(repo.as_deref(), recorded, |r| { + r.create_branch_with_overwrite(&name, start.as_deref(), checkout, overwrite.unwrap_or(false)) + }) }) .await } @@ -1335,7 +1338,16 @@ pub(crate) fn register(d: &mut Dispatcher) { vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await }); d.add("vcs_create_branch", |c| async move { - vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await + vcs_create_branch( + &c.host, + &c.window, + c.arg("repo")?, + c.arg("name")?, + c.arg("start")?, + c.arg("checkout")?, + c.arg("overwrite")?, + ) + .await }); d.add( "vcs_undo_commit", diff --git a/crates/ide-host/tests/vcs_flow.rs b/crates/ide-host/tests/vcs_flow.rs index 031b628da..27db76065 100644 --- a/crates/ide-host/tests/vcs_flow.rs +++ b/crates/ide-host/tests/vcs_flow.rs @@ -151,7 +151,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() { assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty()); // A branch changes the line its way. - ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap(); + ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true, None).await.unwrap(); let theirs = "pub fn format_price(cents: u64) -> String { // feature"; f.edit_line(lib, theirs).await; f.save(lib).await; diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index b2be6a05f..07dd0cd89 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -1087,8 +1087,19 @@ impl Repo { /// Creates branch `name` at `start` (default HEAD), optionally switching to it. A /// remote-tracking start point (`origin/x`) becomes the new branch's upstream. pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> { + self.create_branch_with_overwrite(name, start, checkout, false) + } + + /// Creates a branch or moves its tip when overwrite is explicitly enabled. + pub fn create_branch_with_overwrite( + &self, + name: &str, + start: Option<&str>, + checkout: bool, + overwrite: bool, + ) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - write::create_branch(self, name, start, checkout) + write::create_branch(self, name, start, checkout, overwrite) } /// Undo Commit: resets the current branch to the parent of `commit` (which must be /// HEAD and not a merge) keeping its changes staged; undoing the initial commit diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 1e4549744..6262afe7c 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -641,7 +641,13 @@ fn stash_count(r: &Repo) -> IdeResult { } /// Creates `name` at `start` (default HEAD); a remote-tracking start point is tracked. -pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> { +pub(crate) fn create_branch( + r: &Repo, + name: &str, + start: Option<&str>, + checkout: bool, + overwrite: bool, +) -> IdeResult<()> { let name = name.trim(); let valid = !name.is_empty() && !name.starts_with('-') @@ -658,7 +664,12 @@ pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: let repo = r.gix()?; resolve(&repo, s)?; } - let mut args = if checkout { vec!["switch", "-c", name] } else { vec!["branch", "--", name] }; + let mut args = match (checkout, overwrite) { + (true, false) => vec!["switch", "-c", name], + (true, true) => vec!["switch", "-C", name], + (false, false) => vec!["branch", "--", name], + (false, true) => vec!["branch", "--force", "--", name], + }; args.extend(start); run(r.root(), &args, None).map(|_| ()) } diff --git a/crates/ide-vcs/tests/git_workflows.rs b/crates/ide-vcs/tests/git_workflows.rs index 4103350ec..226de37f1 100644 --- a/crates/ide-vcs/tests/git_workflows.rs +++ b/crates/ide-vcs/tests/git_workflows.rs @@ -950,6 +950,36 @@ fn branch_create_checkout_merge_rebase_delete() { assert!(!repo.branches().unwrap().iter().any(|b| b.name == "unmerged")); } +#[test] +fn branch_overwrite_preserves_the_ref_if_checkout_fails() { + let fx = Fixture::new(); + fx.write("a.txt", "base\n"); + let base = fx.commit_all("base"); + fx.write("a.txt", "main\n"); + let tip = fx.commit_all("main"); + let repo = fx.repo(); + repo.create_branch("topic", None, false).unwrap(); + + repo.create_branch_with_overwrite("topic", Some(&base), false, true).unwrap(); + assert_eq!(fx.git(&["rev-parse", "topic"]), base); + assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip); + + repo.create_branch_with_overwrite("topic", Some(&tip), false, true).unwrap(); + fx.write("a.txt", "dirty\n"); + assert!(repo.create_branch_with_overwrite("topic", Some(&base), true, true).is_err()); + assert_eq!(fx.git(&["rev-parse", "topic"]), tip); + assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main")); + assert_eq!(fx.read("a.txt"), "dirty\n"); + + fx.write("a.txt", "main\n"); + repo.create_branch_with_overwrite("topic", Some(&base), true, true).unwrap(); + assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic")); + assert_eq!(fx.git(&["rev-parse", "HEAD"]), base); + + repo.create_branch_with_overwrite("topic", Some(&tip), true, true).unwrap(); + assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip); +} + #[test] fn new_branch_from_remote_branch_tracks_it() { let (fx, _bare) = Fixture::with_origin(); diff --git a/e2e/gitLogBranchMenu.spec.ts b/e2e/gitLogBranchMenu.spec.ts new file mode 100644 index 000000000..a62e64a3f --- /dev/null +++ b/e2e/gitLogBranchMenu.spec.ts @@ -0,0 +1,90 @@ +import { expect, ipcCalls, stubDesktop, test } from "./fixtures"; + +test("opens a commit's branch menu and acts in its linked repository", async ({ workbench: page }) => { + await stubDesktop(page); + await page.evaluate(async () => { + const primary = "/dev/acme-api"; + const linked = "/dev/acme-web"; + const tip = "1".repeat(40); + const h = window.__ipc.handlers; + h.vcs_roots = () => + [ + { root: primary, head: "main" }, + { root: linked, head: "release" }, + ].map((repo) => ({ ...repo, operation: null, headCommit: tip })); + h.vcs_branch_info = ({ repo }) => ({ + head: repo === linked ? "release" : "main", + headCommit: tip, + upstream: null, + ahead: 0, + behind: 0, + operation: null, + }); + h.vcs_branches = ({ repo }) => + [repo === linked ? "origin/dev" : "origin/other"].map((name) => ({ + name, + commit: tip, + remote: true, + current: false, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: 0, + })); + h.vcs_log = ({ repo }) => { + const summary = repo === linked ? "Linked repository commit" : "Primary repository commit"; + return [ + { + id: tip, + shortId: tip.slice(0, 8), + parents: [], + refs: [repo === linked ? "origin/dev" : "origin/other"], + author: "A", + email: "a@example.com", + timeMs: 0, + summary, + message: summary, + }, + ]; + }; + h.vcs_log_refs = () => ({}); + h.vcs_log_details_batch = ({ revisions }) => + (revisions as string[]).map(() => ({ + message: "Linked repository commit", + containingBranches: [], + signature: "N", + signer: "", + key: "", + fingerprint: "", + })); + h.vcs_commit_files = () => []; + h.vcs_config_get = () => null; + h.vcs_tags = () => []; + h.vcs_status = () => []; + h.vcs_create_branch = () => null; + const vcsModule = "/src/features/vcs/store.ts"; + const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule); + useVcs.setState({ available: true, root: primary, repo: primary }); + await refreshNow(); + const contextModule = "/src/platform/context.ts"; + const { setContext } = await import(/* @vite-ignore */ contextModule); + setContext("vcsAvailable", true); + const actionsModule = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actionsModule); + await executeAction("vcs.showLog"); + }); + + await page.getByRole("option", { name: /Linked repository commit/ }).click({ button: "right" }); + const menu = page.getByRole("menu", { name: "Context menu" }); + await expect(menu.getByRole("menuitem", { name: "Branch 'origin/other'" })).toHaveCount(0); + await menu.getByRole("menuitem", { name: "Branch 'origin/dev'" }).hover(); + await expect(page.getByRole("menuitem", { name: "Pull into 'release' Using Rebase" })).toBeVisible(); + const newBranch = page.getByRole("menuitem", { name: "New Branch from 'origin/dev'…" }); + await newBranch.click(); + const dialog = page.getByRole("dialog", { name: "Create New Branch from origin/dev" }); + await dialog.getByRole("textbox", { name: "New branch name:" }).fill("test-branch"); + await dialog.getByRole("button", { name: "Create", exact: true }).click(); + await expect + .poll(() => ipcCalls(page, "vcs_create_branch")) + .toMatchObject([{ repo: "/dev/acme-web", name: "test-branch", start: "origin/dev", checkout: true }]); +}); diff --git a/e2e/newBranch.spec.ts b/e2e/newBranch.spec.ts index f07067393..acaf0e1f6 100644 --- a/e2e/newBranch.spec.ts +++ b/e2e/newBranch.spec.ts @@ -7,30 +7,47 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures"; const ROOT = "/dev/acme-api"; -async function openNewBranchDialog(page: Page) { +async function openNewBranchDialog(page: Page, from?: string) { await stubDesktop(page); - await page.evaluate(async (root) => { - const h = window.__ipc.handlers; - h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }]; - h.vcs_branch_info = () => ({ - head: "main", - headCommit: "1111111", - upstream: null, - ahead: 0, - behind: 0, - operation: null, - }); - h.vcs_status = () => []; - h.vcs_create_branch = () => null; - const vcsModule = "/src/features/vcs/store.ts"; - const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule); - useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false }); - await refreshNow(); - const actionsModule = "/src/platform/actions.ts"; - const { executeAction } = await import(/* @vite-ignore */ actionsModule); - void executeAction("vcs.newBranch"); - }, ROOT); - const dialog = page.getByRole("dialog", { name: "Create New Branch" }); + await page.evaluate( + async ({ root, from }) => { + const h = window.__ipc.handlers; + h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }]; + h.vcs_branch_info = () => ({ + head: "main", + headCommit: "1111111", + upstream: null, + ahead: 0, + behind: 0, + operation: null, + }); + h.vcs_status = () => []; + h.vcs_branches = () => [ + { + name: "main", + current: true, + remote: false, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: null, + commit: "1111111", + }, + ]; + h.vcs_create_branch = () => null; + const vcsModule = "/src/features/vcs/store.ts"; + const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule); + useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false }); + await refreshNow(); + await loadBranches(); + const actionsModule = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actionsModule); + if (from) void executeAction("vcs.log.newBranch", { id: from, summary: "Selected commit", repo: root }); + else void executeAction("vcs.newBranch"); + }, + { root: ROOT, from }, + ); + const dialog = page.getByRole("dialog", { name: from ? `Create New Branch from ${from}` : "Create New Branch" }); const input = dialog.getByRole("textbox", { name: "New branch name:" }); await expect(input).toBeVisible(); return { dialog, input }; @@ -48,6 +65,29 @@ test("a typed space becomes a separator and Create stays enabled", async ({ work expect(call).toMatchObject({ name: "fix-login-redirect" }); }); +test("overwrites a local branch only after explicitly opting in", async ({ workbench: page }) => { + const from = "2".repeat(40); + const { dialog, input } = await openNewBranchDialog(page, from); + await input.fill("main"); + const create = dialog.getByRole("button", { name: "Create", exact: true }); + await expect(create).toBeDisabled(); + const overwrite = dialog.getByRole("checkbox", { name: "Overwrite existing branch" }); + await expect(overwrite).toBeVisible(); + await expect(overwrite).not.toBeChecked(); + await overwrite.check(); + await expect(create).toBeEnabled(); + await input.fill("new-branch"); + await expect(overwrite).toHaveCount(0); + await input.fill("main"); + await expect(overwrite).not.toBeChecked(); + await expect(create).toBeDisabled(); + await overwrite.check(); + await create.click(); + await expect + .poll(() => ipcCalls(page, "vcs_create_branch")) + .toEqual([{ repo: ROOT, name: "main", start: from, checkout: true, overwrite: true }]); +}); + test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => { const { input } = await openNewBranchDialog(page); await input.fill("ab"); diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 29cc34b92..cef9f7eb7 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -268,8 +268,9 @@ pub async fn vcs_create_branch( name: String, start: Option, checkout: bool, + overwrite: Option, ) -> IdeResult<()> { - ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout).await + ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout, overwrite).await } /// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bee4317f9..b3810cfc9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,8 @@ mod logging; mod macos; mod menu; mod plugin_web; +#[cfg(target_os = "macos")] +mod relaunch; mod remote_dev; mod remote_wsl; @@ -248,6 +250,9 @@ pub fn run() { } // Cleanup must finish before update/restart replaces this process. if app.try_state::().is_some_and(|w| w.restart_requested()) { + #[cfg(target_os = "macos")] + relaunch::restart(&app.env()); + #[cfg(not(target_os = "macos"))] tauri::process::restart(&app.env()); } } diff --git a/src-tauri/src/relaunch.rs b/src-tauri/src/relaunch.rs new file mode 100644 index 000000000..d7703e0bb --- /dev/null +++ b/src-tauri/src/relaunch.rs @@ -0,0 +1,88 @@ +//! Restarting the app on macOS once this process has exited. +//! +//! `tauri::process::restart` launches the new instance while this one is still running. +//! After an update has replaced the app bundle, the Dock then gives the new instance a +//! tile of its own instead of the pinned one. A detached shell waits for this process +//! to exit and only then opens the bundle. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const WAIT_THEN_OPEN: &str = r#"pid=$1; opener=$2; app=$3; shift 3; while kill -0 "$pid" 2>/dev/null; do sleep 0.1; done; exec "$opener" -n "$app" --args "$@""#; + +/// Restarts the app; never returns. +pub fn restart(env: &tauri::Env) -> ! { + if let Some(bundle) = tauri::process::current_binary(env).ok().and_then(|bin| app_bundle(&bin)) { + let args = env.args_os.iter().skip(1).cloned().collect::>(); + match wait_then_open(std::process::id(), Path::new("/usr/bin/open"), &bundle, &args).spawn() { + Ok(_) => std::process::exit(0), + Err(e) => tracing::error!("failed to schedule the restart: {e}"), + } + } + tauri::process::restart(env) +} + +/// The `.app` bundle holding `binary`, when it sits at `/Contents/MacOS/`. +fn app_bundle(binary: &Path) -> Option { + let macos = binary.parent()?; + let contents = macos.parent()?; + if macos.file_name()? != "MacOS" || contents.file_name()? != "Contents" { + return None; + } + contents.parent().map(Path::to_path_buf) +} + +fn wait_then_open(pid: u32, opener: &Path, bundle: &Path, args: &[OsString]) -> Command { + use std::os::unix::process::CommandExt; + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg(WAIT_THEN_OPEN) + .arg("openidea-restart") + .arg(pid.to_string()) + .arg(opener) + .arg(bundle) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0); + command +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn finds_the_bundle_of_its_binary() { + assert_eq!( + app_bundle(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea")), + Some(PathBuf::from("/Applications/OpenIDEA.app")) + ); + assert_eq!(app_bundle(Path::new("/repo/target/debug/openidea")), None); + assert_eq!(app_bundle(Path::new("/x/MacOS/openidea")), None); + } + + #[test] + fn opens_the_bundle_only_after_the_process_exits() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("opened"); + let opener = dir.path().join("opener"); + std::fs::write(&opener, format!("#!/bin/sh\nprintf '%s|' \"$@\" > '{}'\n", log.display())).unwrap(); + std::fs::set_permissions(&opener, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + + let mut running = Command::new("/bin/sleep").arg("1").spawn().unwrap(); + let pid = running.id(); + let reaper = std::thread::spawn(move || running.wait()); + let args = [OsString::from("--flag"), OsString::from("a b")]; + let started = Instant::now(); + wait_then_open(pid, &opener, Path::new("/Apps/X.app"), &args).status().unwrap(); + + assert!(started.elapsed() >= Duration::from_millis(700), "opened before the process exited"); + assert!(reaper.join().unwrap().unwrap().success()); + assert_eq!(std::fs::read_to_string(&log).unwrap(), "-n|/Apps/X.app|--args|--flag|a b|"); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index 6e0904ec4..5ff5d418f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -534,7 +534,7 @@ export const commands = { /** "Restore" of the tag-deleted notification. */ vcsRestoreTag: (repo: string | null, deleted: DeletedTag) => typedError(__TAURI_INVOKE("vcs_restore_tag", { repo, deleted })), /** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */ - vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })), + vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean, overwrite: boolean | null) => typedError(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout, overwrite })), /** * Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged. * Returns the undone commit's message. diff --git a/src/editor/lsp/spelling.test.ts b/src/editor/lsp/spelling.test.ts index 62550c5de..9017b82ad 100644 --- a/src/editor/lsp/spelling.test.ts +++ b/src/editor/lsp/spelling.test.ts @@ -119,6 +119,27 @@ describe("Typo inspection", () => { expect(spellingIntentions(view)[0]!.label).toBe("Typo: Rename to…"); }); + it("in a read-only document saves to dictionaries but changes nothing in place", async () => { + vi.useFakeTimers(); + useProject.setState({ project: { root: "/p" } as never, settings: {} as never }); + const view = new EditorView({ + state: EditorState.create({ + doc: "// mesage here", + extensions: [ + lspDoc.of({ doc: 7, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: true }), + spellingExtension(), + ], + }), + }); + views.push(view); + await settle(); + expect(spellCheck).toHaveBeenCalled(); + view.dispatch({ selection: { anchor: 5 } }); + expect(spellingIntentions(view).map((i) => i.label)).toEqual(["Save 'mesage' to dictionary"]); + await spellingIntentions(view)[0]!.run(); + expect(settingsSet).toHaveBeenCalledWith("project", "spelling.projectWords", JSON.stringify(["mesage"])); + }); + it("checks only what the Typo options include, and re-checks when they change", async () => { vi.useFakeTimers(); const options = (o: Record) => diff --git a/src/editor/lsp/spelling.ts b/src/editor/lsp/spelling.ts index 1a10926cc..6220ee49b 100644 --- a/src/editor/lsp/spelling.ts +++ b/src/editor/lsp/spelling.ts @@ -134,11 +134,15 @@ async function changeTo(view: EditorView, typo: PlacedTypo) { }); } -/** Typo quick fixes at the caret (⌥↵), or none. */ +/** + * Typo quick fixes at the caret (⌥↵), or none. Dictionary saves write + * settings, so they are offered in read-only documents too (library sources, + * stubs); only the in-place correction needs a writable file. + */ export function spellingIntentions(view: EditorView): ListPopupItem[] { const doc = docOf(view); const sel = view.state.selection.main; - if (!doc || doc.readOnly || !sel.empty || !spellingEnabled()) return []; + if (!doc || !sel.empty || !spellingEnabled()) return []; const typo = typoAt(view.state, sel.head); if (!typo) return []; const hasProject = !!useProject.getState().project; @@ -162,15 +166,20 @@ export function spellingIntentions(view: EditorView): ListPopupItem[] { { kind: "separator" }, ...intentionControls({ id: SPELLING_INSPECTION, name: "Typo" }), ]; + const change: ListPopupItem[] = doc.readOnly + ? [] + : [ + { + id: "spelling.change", + label: typo.context === "code" ? "Typo: Rename to…" : "Typo: Change to…", + icon: "bulb-fix", + emphasis: true, + run: () => changeTo(view, typo), + submenu: saveSubmenu, + }, + ]; return [ - { - id: "spelling.change", - label: typo.context === "code" ? "Typo: Rename to…" : "Typo: Change to…", - icon: "bulb-fix", - emphasis: true, - run: () => changeTo(view, typo), - submenu: saveSubmenu, - }, + ...change, { id: "spelling.save", label: tx`Save '${typo.word}' to dictionary`, diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 0a746dd69..06ae38ff3 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1121,7 +1121,9 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { { label: "Browse Repository at Revision", run: () => browseRepositoryAt(c) }, ]; const many = selection.length > 1 && selection.some((x) => commitKey(x) === commitKey(c)); - const head = (commitRepo(c) ? useVcs.getState().heads[commitRepo(c)!]?.head : useVcs.getState().branch?.head) ?? null; + const repo = commitRepo(c); + const vcs = useVcs.getState(); + const head = (repo ? vcs.heads[repo]?.head : vcs.branch?.head) ?? null; const { outgoing } = useGitLog.getState(); const pushed = (x: Commit) => commitRepo(x) === undefined ? !!outgoing && !outgoing.has(x.id) : isPushed(commitKey(x)); @@ -1148,6 +1150,22 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { ((commitRepo(c) === undefined ? !!outgoing : useGitLog.getState().tracked.has(commitRepo(c) ?? "")) ? !!outgoing?.has(commitKey(c)) : true); + const branches = ((c as RepoCommit).refBranches ?? []).filter((branch) => !branch.current); + const branchActions: MenuItem[] = branches.map((branch) => { + const menu = branchMenu(branch, head); + return { + label: tx`Branch '${branch.name}'`, + submenu: repo ? inRepo(repo, menu) : menu, + }; + }); + if (!branchActions.length) + branchActions.push({ + kind: "action", + action: "vcs.log.rebaseOntoCommit", + arg: c, + label: tx`Rebase '${head ?? "HEAD"}' onto Selected Commit`, + disabled: !head, + }); return [ { kind: "action", action: "vcs.log.copyRevision", arg: c }, { @@ -1173,13 +1191,7 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { { kind: "action", action: "vcs.log.interactiveRebase", arg: c, disabled: pushedCommit }, { kind: "action", action: "vcs.log.pushUpToHere", arg: c, disabled: !canPushHere }, { kind: "separator" }, - { - kind: "action", - action: "vcs.log.rebaseOntoCommit", - arg: c, - label: tx`Rebase '${head ?? "HEAD"}' onto Selected Commit`, - disabled: !head, - }, + ...(branchActions.length > 2 ? [{ label: "Branches", submenu: branchActions }] : branchActions), { kind: "action", action: "vcs.log.newBranch", arg: c }, { kind: "action", action: "vcs.log.newTag", arg: c }, { kind: "separator" }, diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index 2facfcde1..1805f6440 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -31,7 +31,7 @@ import { showCompareBranches } from "./CompareBranches"; import { showDiffWithWorkingTree } from "./operations"; import type { CommonBranch } from "./repoBranches"; import { runRemote, saveAll } from "./ops"; -import { focusRepo, useVcs } from "./store"; +import { currentRepo, focusRepo, useVcs } from "./store"; import { tx } from "@/platform/i18n"; /** `git pull ` into the current branch (`origin/feat/x` → origin, feat/x). */ @@ -173,6 +173,10 @@ export function commonBranchMenu(b: CommonBranch, roots: readonly string[], done /** `items` acting on repository `root`: it is selected (with its branches loaded) before each entry runs. */ export function inRepo(root: string, items: MenuItem[]): MenuItem[] { + const runInRepo = (run: () => unknown) => () => + void focusRepo(root).then(() => { + if (currentRepo() === root) return run(); + }); return items.map((item): MenuItem => { if (item.kind === "separator" || item.kind === "header") return item; if (item.kind === "action") @@ -180,12 +184,12 @@ export function inRepo(root: string, items: MenuItem[]): MenuItem[] { label: item.label ?? item.action, icon: item.icon, disabled: item.disabled, - run: () => void focusRepo(root).then(() => executeAction(item.action, item.arg)), + run: runInRepo(() => executeAction(item.action, item.arg)), }; const run = item.run; return { ...item, - run: run && (() => void focusRepo(root).then(run)), + run: run && runInRepo(run), submenu: item.submenu && inRepo(root, item.submenu), }; }); diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index d2f6c0dfa..16a5693ac 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -35,15 +35,18 @@ function rememberBranch(name: string | null | undefined) { } /** Valid-enough git ref name check (mirrors `git check-ref-format` basics). */ -export function validateBranchName(name: string): string | null { +export function validateBranchName( + name: string, + overwrite = false, + branches: readonly Branch[] = useVcs.getState().branches, +): string | null { const n = name.trim(); if (!n) return "Branch name must not be empty"; if (/[\s~^:?*[\\\p{Cc}]/u.test(n)) return "Branch name contains invalid characters"; if (n.startsWith("-") || n.startsWith("/") || n.endsWith("/") || n.endsWith(".") || n.endsWith(".lock")) return "Invalid branch name"; if (n.includes("..") || n.includes("//") || n.includes("@{") || n === "@") return "Invalid branch name"; - const exists = useVcs.getState().branches.some((b) => !b.remote && b.name === n); - if (exists) return `Branch '${n}' already exists`; + if (!overwrite && branches.some((b) => !b.remote && b.name === n)) return tx`Branch '${n}' already exists`; return null; } @@ -154,16 +157,18 @@ export async function checkoutBranch(name: string) { } /** New Branch: creates a branch from HEAD (or `from`) and, by default, checks it out. */ -export async function newBranch(from?: string) { - const repo = currentRepo(); +export async function newBranch(from?: string, repo = currentRepo()) { + const state = useVcs.getState(); + const selected = repo === state.repo; + const previous = selected ? state.branch?.head : repo ? state.heads[repo]?.head : null; + const branches = + selected && state.branchesLoaded + ? state.branches + : await call(commands.vcsBranches(repo)).catch(() => (selected ? state.branches : [])); // IDEA opens the dialog with a suggestion instead of an empty field: a // remote's name without the prefix, the source ref as `-copy`, or the // current branch for a plain New Branch from HEAD. - const suggestion = from - ? from.includes("/") - ? shortRemoteName(from) - : `${from}-copy` - : (useVcs.getState().branch?.head ?? ""); + const suggestion = from ? (from.includes("/") ? shortRemoteName(from) : `${from}-copy`) : (previous ?? ""); const r = await promptText({ title: from ? tx`Create New Branch from ${from}` : "Create New Branch", label: "New branch name:", @@ -172,15 +177,15 @@ export async function newBranch(from?: string) { mono: true, okLabel: "Create", sanitize: cleanUpBranchNameOnTyping, - validate: validateBranchName, + validate: (name, overwrite) => validateBranchName(name, overwrite, branches), + overwriteBranch: (name) => branches.some((b) => !b.remote && b.name === name.trim()), validateInitial: true, checkbox: { label: "Checkout branch", initial: true }, }); if (!r) return; const checkout = r.checked; - const previous = useVcs.getState().branch?.head; if (checkout) await saveAll(); - const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout)); + const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout, r.overwrite ?? false)); const ok = await runOp(tx`Create branch ${r.value}`, create, { success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`, }); @@ -525,9 +530,13 @@ export async function newBranchInRepos(roots: readonly string[], from?: string) await eachRepo( roots, (root) => - runOp(tx`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), { - refresh: false, - }).then((done) => (done === undefined ? undefined : true)), + runOp( + tx`Create branch ${name}`, + () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked, false)), + { + refresh: false, + }, + ).then((done) => (done === undefined ? undefined : true)), r.checked ? `Created and checked out ${name}` : `Created branch ${name}`, { undo: tx`delete branch ${name}`, diff --git a/src/features/vcs/dialogs.tsx b/src/features/vcs/dialogs.tsx index 82bfefd18..b2089679f 100644 --- a/src/features/vcs/dialogs.tsx +++ b/src/features/vcs/dialogs.tsx @@ -42,18 +42,20 @@ export interface PromptOptions { okLabel?: string; mono?: boolean; /** Returns an error message, or null when valid. */ - validate?: (value: string) => string | null; + validate?: (value: string, overwrite: boolean) => string | null; /** Rewrites the field as the user types or pastes (e.g. spaces in a branch name). */ sanitize?: (value: string) => string; /** Show the prefilled value's problem (e.g. the branch exists) before it is edited. */ validateInitial?: boolean; /** Optional checkbox below the field. */ checkbox?: { label: string; initial: boolean }; + overwriteBranch?: (value: string) => boolean; } export interface PromptResult { value: string; checked: boolean; + overwrite?: boolean; } export function promptText(opts: PromptOptions): Promise { @@ -75,11 +77,13 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null) const { ref, clean } = useSanitizedField(sanitize); const [value, setValue] = useState(initial); const [checked, setChecked] = useState(checkbox?.initial ?? false); + const [overwrite, setOverwrite] = useState(false); const [touched, setTouched] = useState(!!props.validateInitial && initial !== ""); - const error = validate ? validate(value) : value.trim() ? null : "Value must not be empty"; + const canOverwrite = props.overwriteBranch?.(value) ?? false; + const error = validate ? validate(value, canOverwrite && overwrite) : value.trim() ? null : "Value must not be empty"; const submit = () => { setTouched(true); - if (!error) onDone({ value: value.trim(), checked }); + if (!error) onDone({ value: value.trim(), checked, ...(canOverwrite ? { overwrite } : {}) }); }; return ( { setTouched(true); - setValue(clean(e)); + const next = clean(e); + if (next.trim() !== value.trim()) setOverwrite(false); + setValue(next); }} /> {touched && error && value &&
{error}
} {checkbox && } + {canOverwrite && }
); diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 4becc6a35..96091d9ad 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -445,7 +445,7 @@ export function registerHistoryActions(): () => void { category: "Git Log", icon: "branch", when: VCS, - run: withCommit((c) => newBranch(c.id)), + run: withCommit((c) => newBranch(c.id, commitRepo(c) ?? currentRepo())), }, { id: "vcs.log.newTag", diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index de7015e2e..071d26363 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -973,13 +973,23 @@ function loadPage(replace: boolean, id: string, before = false): Promise { } else page = await fetchPage(null); } else page = await fetchPage(null); } + const repoBranches = repo ? (refs[repo] ?? []) : []; + const remoteNames = repo ? repoBranches.filter((b) => b.remote).map((b) => b.name) : undefined; + const branchesByTip = new Map(); + for (const branch of repoBranches) { + if (!branch.commit) continue; + const atTip = branchesByTip.get(branch.commit); + if (atTip) atTip.push(branch); + else branchesByTip.set(branch.commit, [branch]); + } return page.map((c) => ({ ...c, repo, user, userName, readOnly: tab?.readOnly === true, - remoteNames: repo ? refs[repo]?.filter((b) => b.remote).map((b) => b.name) : undefined, + remoteNames, + refBranches: branchesByTip.get(c.id)?.filter((branch) => c.refs.includes(branch.name)), })); }), ); diff --git a/src/features/vcs/multiRoot.ts b/src/features/vcs/multiRoot.ts index 6ce6faae3..1753c5d58 100644 --- a/src/features/vcs/multiRoot.ts +++ b/src/features/vcs/multiRoot.ts @@ -8,6 +8,7 @@ export type RepoCommit = Commit & { user?: string | null; userName?: string | null; remoteNames?: string[]; + refBranches?: Branch[]; readOnly?: boolean; }; export const commitRepo = (c: Commit | undefined): string | null | undefined => (c as RepoCommit | undefined)?.repo; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 1bce8ccc2..4d3ee08db 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,6 @@ { + "Overwrite existing branch": "Overwrite existing branch", + "Branch '{n}' already exists": "Branch '{n}' already exists", "Cannot copy reference": "Cannot copy reference", "Decompiled assembly (read-only).": "Decompiled assembly (read-only).", "Connect through:": "Connect through:", @@ -62,5 +64,6 @@ "Regenerate Response": "Regenerate Response", "Branch Conversation from Here": "Branch Conversation from Here", "Couldn't regenerate the response": "Couldn't regenerate the response", - "Reasoning": "Reasoning" + "Reasoning": "Reasoning", + "Branch '{name}'": "Branch '{name}'" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 185ec7972..935bb2d21 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -448,6 +448,8 @@ "Checkout Tag or Revision": "签出标签或修订", "Checkout Tag or Revision…": "签出标签或修订…", "Checkout branch": "签出分支", + "Overwrite existing branch": "覆盖现有分支", + "Branch '{n}' already exists": "分支 '{n}' 已存在", "Checks out the selected pull request, a number given as argument, or asks for one.": "签出所选拉取请求或参数指定编号的拉取请求,否则询问编号。", "Cherry-Pick": "拣选", "Children": "子项", @@ -4267,6 +4269,7 @@ "Branch {name}": "分支 {name}", "Tag {name}": "标签 {name}", "Branch {branch} of {repository}": "{repository} 的分支 {branch}", + "Branch '{name}'": "分支 '{name}'", "Branches of {root}": "{root} 的分支", "Include {name} in commit": "在提交中包含 {name}", "Compare {current} with {branch}": "比较 {current} 与 {branch}",