From 715ebf19e2ce9caa30a385791b75a0f1cfc15c85 Mon Sep 17 00:00:00 2001 From: Muu Date: Sat, 7 Nov 2026 10:36:09 +0800 Subject: [PATCH 1/4] fix(spelling): keep dictionary saves on read-only documents (#1756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typo quick fixes vanished entirely in read-only documents (library sources, stubs such as pyrefly's bundled typeshed), although saving a word to the project or application dictionary only writes settings. Only the in-place Change to…/Rename to… fix now needs a writable document; Save 'word' to dictionary and its per-level submenu are offered everywhere. Co-authored-by: muutot <92625353+muutot@users.noreply.github.com> --- src/editor/lsp/spelling.test.ts | 21 +++++++++++++++++++++ src/editor/lsp/spelling.ts | 29 +++++++++++++++++++---------- 2 files changed, 40 insertions(+), 10 deletions(-) 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`, From 9298be8798b1e213fd8fa8e8c1e5cf768ad699c6 Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Sat, 7 Nov 2026 07:36:15 +0500 Subject: [PATCH 2/4] fix(vcs): allow explicitly overwriting a local branch (#1851) The New Branch dialog refused any existing local name, so a branch could not be moved to a selected commit. When the name matches a local branch of the target repository, the dialog now offers an unchecked Overwrite existing branch option (git switch -C / git branch --force); the consent clears when the name changes, and Git's checkout checks keep the old tip and local changes if switching fails. New Branch from a Git Log commit also targets that commit's repository in a multi-repository project. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> --- crates/ide-host/src/ops/vcs/commands.rs | 16 ++++- crates/ide-host/tests/vcs_flow.rs | 2 +- crates/ide-vcs/src/lib.rs | 13 +++- crates/ide-vcs/src/write.rs | 15 ++++- crates/ide-vcs/tests/git_workflows.rs | 30 +++++++++ e2e/newBranch.spec.ts | 86 ++++++++++++++++++------- src-tauri/src/commands/vcs.rs | 3 +- src/bindings.ts | 2 +- src/features/vcs/branchOps.ts | 41 +++++++----- src/features/vcs/dialogs.tsx | 15 +++-- src/features/vcs/historyActions.tsx | 2 +- src/platform/locales/text/en.json | 2 + src/platform/locales/text/zh.json | 2 + 13 files changed, 177 insertions(+), 52 deletions(-) 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/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/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/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/platform/locales/text/en.json b/src/platform/locales/text/en.json index 113684bc4..3963bbfe7 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:", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c70435fb7..d0097a991 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": "子项", From 7e7da950fcd3569ed22b7ba5ab918e0ab67f4e16 Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Sat, 7 Nov 2026 07:37:24 +0500 Subject: [PATCH 3/4] fix(vcs): show branch actions in the Git Log commit menu (#1852) Right-clicking a commit with branch labels offered commit actions only. The menu now has a Branch 'name' submenu with the existing branch actions for each branch at that commit other than the current one, grouped under Branches when there are more than two, and keeps Rebase onto Selected Commit for commits without branches. The actions run against the commit's repository in a multi-repository project. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> --- e2e/gitLogBranchMenu.spec.ts | 90 +++++++++++++++++++++++++++++++ src/features/vcs/GitLog.tsx | 28 +++++++--- src/features/vcs/branchMenu.ts | 10 ++-- src/features/vcs/logStore.ts | 12 ++++- src/features/vcs/multiRoot.ts | 1 + src/platform/locales/text/en.json | 3 +- src/platform/locales/text/zh.json | 1 + 7 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 e2e/gitLogBranchMenu.spec.ts 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/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/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 3963bbfe7..f88c95baf 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -57,5 +57,6 @@ "Recommended": "Recommended", "No preview": "No preview", "Running in the background": "Running in the background", - "Couldn't stop the background task": "Couldn't stop the background task" + "Couldn't stop the background task": "Couldn't stop the background task", + "Branch '{name}'": "Branch '{name}'" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d0097a991..967e047c2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4269,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}", From 7a0c9354fa725b2d151e936656a86ee954b17776 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:38:22 +0800 Subject: [PATCH 4/4] fix(app): keep the pinned Dock icon after an update on macOS (#1906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The updater swaps the whole app bundle, and tauri::process::restart then opens the new bundle with `open -n` while the old process is still alive, so the Dock gives the new instance its own tile and leaves the pinned one stopped. Restart on macOS now hands the relaunch to a detached shell that waits for this process to exit before opening the bundle. This covers every restart path (updates and Invalidate Caches › Restart); Windows and Linux keep Tauri's restart. --- src-tauri/src/lib.rs | 5 +++ src-tauri/src/relaunch.rs | 88 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src-tauri/src/relaunch.rs 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|"); + } +}