From 039ec09122f53c7fa24d41ace5d12504f6b650ef Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:10:09 +0700 Subject: [PATCH] feat(vcs): pick each repository's remote and target branch in the multi-repository Push dialog (#2342) Co-authored-by: NguyenBang --- .../vcs/__tests__/multiPushTarget.test.tsx | 112 ++++++++++ src/features/vcs/__tests__/pushTarget.test.ts | 88 ++++++++ .../vcs/__tests__/remoteResults.test.ts | 8 + src/features/vcs/common.module.css | 23 ++ src/features/vcs/ops.ts | 3 +- src/features/vcs/pushTarget.ts | 62 ++++++ src/features/vcs/remoteUi.tsx | 201 +++++++++++++----- src/platform/locales/text/en.json | 4 +- src/platform/locales/text/zh.json | 4 +- 9 files changed, 445 insertions(+), 60 deletions(-) create mode 100644 src/features/vcs/__tests__/multiPushTarget.test.tsx create mode 100644 src/features/vcs/__tests__/pushTarget.test.ts create mode 100644 src/features/vcs/pushTarget.ts diff --git a/src/features/vcs/__tests__/multiPushTarget.test.tsx b/src/features/vcs/__tests__/multiPushTarget.test.tsx new file mode 100644 index 000000000..67653cccb --- /dev/null +++ b/src/features/vcs/__tests__/multiPushTarget.test.tsx @@ -0,0 +1,112 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { RemoteOp } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ + ops: [] as Array<{ repo: string | null; op: RemoteOp }>, + failing: new Set(), +})); + +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + Channel: class { + onmessage: (line: string) => void = () => {}; + }, +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data }); + const remote = (name: string) => ({ name, fetchUrl: `/remotes/${name}`, pushUrl: `/remotes/${name}` }); + return { + ...real, + commands: { + ...real.commands, + vcsBranchInfo: (repo: string) => + ok({ head: "feat-x", headCommit: "c1", upstream: "origin/feat-x", ahead: 1, behind: 0, operation: null, repo }), + vcsRemotes: (repo: string) => ok(repo === "/p/b" ? [remote("origin"), remote("fork")] : [remote("origin")]), + vcsLog: (repo: string) => + ok([{ id: `${repo}-1`, shortId: "c1", parents: ["c0"], summary: "Work", author: "me", timeMs: 0 }]), + vcsHasHook: () => ok(false), + vcsRemote: (repo: string | null, _request: number, op: RemoteOp) => { + mocks.ops.push({ repo, op }); + return repo && mocks.failing.has(repo) + ? Promise.resolve({ status: "error", error: { kind: "failed", message: "remote hung up" } }) + : ok(null); + }, + cancelRequest: () => ok(null), + }, + }; +}); + +import { provideNotificationService } from "@/workbench/contributions"; +import { VcsDialogHost } from "../dialogs"; +import { showPushDialog } from "../remoteUi"; +import { useVcs } from "../store"; + +type Note = { title: string; body?: string }; +let notes: Note[] = []; + +beforeEach(() => { + mocks.ops = []; + mocks.failing = new Set(); + notes = []; + useVcs.setState({ root: "/p", roots: ["/p/a", "/p/b", "/p/c"], repo: "/p/a", remoteBusy: null, available: false }); + provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} }); +}); + +it("pushes each repository to its own remote and target branch, reporting the one that failed", async () => { + mocks.failing.add("/p/b"); + render(); + await act(() => showPushDialog()); + + const targetOfA = await screen.findByRole("textbox", { name: "Target branch of a" }); + expect(targetOfA).toHaveValue("feat-x"); + expect(screen.getByRole("checkbox", { name: "Push tags:" })).toBeInTheDocument(); + fireEvent.change(targetOfA, { target: { value: "release-2.1" } }); + // A retargeted push carries no tags, as in the single-repository dialog. + expect(screen.queryByRole("checkbox", { name: "Push tags:" })).toBeNull(); + + // Only a repository with several remotes offers to pick one. + expect(screen.queryByRole("button", { name: "Remote of a" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Remote of b" })); + fireEvent.click(await screen.findByRole("option", { name: "fork" })); + + fireEvent.click(screen.getByRole("button", { name: "Push" })); + await waitFor(() => expect(mocks.ops).toHaveLength(3)); + + expect(mocks.ops).toEqual([ + { + repo: "/p/a", + op: { + op: "pushBranch", + branch: "feat-x", + revision: null, + force_with_lease: false, + target: "release-2.1", + remote: null, + set_upstream: false, + no_verify: false, + }, + }, + { + repo: "/p/b", + op: { + op: "pushBranch", + branch: "feat-x", + revision: null, + force_with_lease: false, + target: null, + remote: "fork", + set_upstream: false, + no_verify: false, + }, + }, + { + repo: "/p/c", + op: { op: "push", force_with_lease: false, set_upstream: false, tags: null, no_verify: false }, + }, + ]); + await waitFor(() => expect(notes.map((n) => n.title)).toContain("Push partially failed")); + expect(notes.find((n) => n.title === "Push partially failed")?.body).toContain("Not pushed: b."); +}); diff --git a/src/features/vcs/__tests__/pushTarget.test.ts b/src/features/vcs/__tests__/pushTarget.test.ts new file mode 100644 index 000000000..dce91bd90 --- /dev/null +++ b/src/features/vcs/__tests__/pushTarget.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { pushOperation, type PushChoice } from "../pushTarget"; + +const tracked: PushChoice = { + head: "feat-x", + tracked: "origin/feat-x", + remote: "origin", + target: "feat-x", + forceWithLease: false, + setUpstream: false, + tags: null, + noVerify: false, +}; + +describe("the operation a push dialog sends", () => { + it("is the plain push of the tracked branch while remote and target are unchanged", () => { + expect(pushOperation({ ...tracked, tags: "all" })).toEqual({ + op: "push", + force_with_lease: false, + set_upstream: false, + tags: "all", + no_verify: false, + }); + // No remote picked yet, or the default name with stray spaces, or a cleared field. + expect(pushOperation({ ...tracked, remote: null }).op).toBe("push"); + expect(pushOperation({ ...tracked, target: " feat-x " }).op).toBe("push"); + expect(pushOperation({ ...tracked, target: "" }).op).toBe("push"); + }); + + it("pushes to another branch of the tracked remote when the target changes", () => { + expect(pushOperation({ ...tracked, target: " release-2.1 " })).toEqual({ + op: "pushBranch", + branch: "feat-x", + revision: null, + force_with_lease: false, + target: "release-2.1", + remote: null, + set_upstream: false, + no_verify: false, + }); + }); + + it("pushes the tracked branch name to another remote when the remote changes", () => { + expect(pushOperation({ ...tracked, remote: "fork" })).toMatchObject({ + op: "pushBranch", + branch: "feat-x", + target: null, + remote: "fork", + }); + }); + + it("sets the upstream of a branch without one on the plain push", () => { + expect(pushOperation({ ...tracked, tracked: null, remote: null })).toMatchObject({ + op: "push", + set_upstream: true, + }); + expect(pushOperation({ ...tracked, tracked: null, remote: null, target: "feat-y" })).toMatchObject({ + op: "pushBranch", + target: "feat-y", + remote: null, + }); + }); + + it("keeps force-with-lease and the hook choice on both kinds", () => { + const force = { ...tracked, forceWithLease: true, noVerify: true }; + expect(pushOperation(force)).toMatchObject({ op: "push", force_with_lease: true, no_verify: true }); + expect(pushOperation({ ...force, target: "release-2.1" })).toMatchObject({ + op: "pushBranch", + force_with_lease: true, + no_verify: true, + }); + }); + + it("drops tags once the target changes: pushing a branch carries none", () => { + const op = pushOperation({ ...tracked, tags: "all", target: "release-2.1" }); + expect(op.op).toBe("pushBranch"); + expect(op).not.toHaveProperty("tags"); + }); + + it("pushes another branch or a revision explicitly, without moving the upstream of a partial push", () => { + expect(pushOperation({ ...tracked, explicit: true })).toMatchObject({ op: "pushBranch", target: null }); + expect(pushOperation({ ...tracked, explicit: true, revision: "abc123", setUpstream: true })).toMatchObject({ + op: "pushBranch", + revision: "abc123", + set_upstream: false, + }); + }); +}); diff --git a/src/features/vcs/__tests__/remoteResults.test.ts b/src/features/vcs/__tests__/remoteResults.test.ts index fc32d8a0b..68d90e15f 100644 --- a/src/features/vcs/__tests__/remoteResults.test.ts +++ b/src/features/vcs/__tests__/remoteResults.test.ts @@ -69,6 +69,14 @@ describe("push result notification", () => { expect(await pushTitle()).toMatch(/successful$/); }); + it("does not name the upstream when the push went to another remote", async () => { + // `ahead` counts against the upstream, so it says nothing about what another remote received. + branch = { upstream: "origin/main", ahead: 1 }; + const op = { op: "pushBranch", branch: "main", remote: "fork", target: null } as never; + expect(await runRemote(op, undefined, ["/p"])).toBe(true); + expect(notes.at(-1)!.title).toMatch(/successful$/); + }); + it("is translated as a whole, count included", async () => { await setLocale("zh"); branch = { upstream: "origin/main", ahead: 3 }; diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index fc399915c..53dbd626f 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -275,6 +275,29 @@ width: 220px; margin-left: 6px; } +/* Fits the remote and target fields into a 22px tree row. */ +.pushRowTarget { + --h-field: var(--h-control-small); + --h-control: var(--h-control-small); + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + font-size: var(--fs-small); +} +.pushRowTarget button { + font-size: var(--fs-small); +} +.pushRowSeparator { + color: var(--fg-faint); +} +.pushRowInput { + min-width: 0; + font-size: var(--fs-small); +} +.pushRowInput[data-changed]:not(:focus-visible) { + box-shadow: inset 0 0 0 1px var(--accent); +} .choiceList { max-height: 240px; overflow: auto; diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index a58f61005..01fc071ad 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -211,7 +211,8 @@ export async function runRemote( const before = isUpdate(op) ? await publishedTip(repo, op) : null; // The push summary counts what the push carries: the commits the branch is // ahead of its upstream before the push (pushing does not move the local head). - if (op.op === "push" || (op.op === "pushBranch" && !op.target)) { + // Another remote or target receives other commits than "ahead of the upstream". + if (op.op === "push" || (op.op === "pushBranch" && !op.target && !op.remote)) { const info = await call(commands.vcsBranchInfo(repo)).catch(() => null); if (info?.upstream && info.ahead > 0) pushed.push({ to: info.upstream, commits: info.ahead }); } diff --git a/src/features/vcs/pushTarget.ts b/src/features/vcs/pushTarget.ts new file mode 100644 index 000000000..2be6bcae1 --- /dev/null +++ b/src/features/vcs/pushTarget.ts @@ -0,0 +1,62 @@ +/** Which push a Push dialog sends for one repository, from the remote and target picked in it. */ +import type { PushTags, RemoteOp } from "@/bindings"; + +export type PushOp = Extract; + +export interface PushChoice { + /** Local branch being pushed. */ + head: string; + /** Its upstream as `remote/branch`, or null. */ + tracked: string | null; + /** Remote picked in the dialog; null keeps the tracked one. */ + remote: string | null; + /** Target branch field as typed; empty keeps the default. */ + target: string; + /** Another branch than the checked-out one: always pushed by name. */ + explicit?: boolean; + /** Push only up to this commit. */ + revision?: string | null; + forceWithLease: boolean; + setUpstream: boolean; + /** Dropped once the push is retargeted: `pushBranch` carries no tags. */ + tags: PushTags | null; + noVerify: boolean; +} + +/** `remote/branch` split at the first slash. */ +export function splitTracked(tracked: string): { remote: string; branch: string } { + const slash = tracked.indexOf("/"); + return { remote: tracked.slice(0, slash), branch: tracked.slice(slash + 1) }; +} + +/** + * The plain push while remote and target are those of the upstream, which keeps tags + * and the update-and-retry of a rejected push; otherwise `git push :` + * naming only what differs from the upstream. + */ +export function pushOperation(c: PushChoice): PushOp { + const upstream = c.tracked ? splitTracked(c.tracked) : null; + const defaultTarget = upstream?.branch ?? c.head; + const edited = c.target.trim(); + const retarget = !!edited && edited !== defaultTarget; + const remoteChanged = !!c.remote && c.remote !== (upstream?.remote ?? null); + const revision = c.revision ?? null; + if (c.explicit || revision || retarget || remoteChanged) + return { + op: "pushBranch", + branch: c.head, + revision, + force_with_lease: c.forceWithLease, + target: retarget ? edited : null, + remote: remoteChanged ? c.remote : null, + set_upstream: c.setUpstream && !revision, + no_verify: c.noVerify, + }; + return { + op: "push", + force_with_lease: c.forceWithLease, + set_upstream: c.setUpstream || !c.tracked, + tags: c.tags, + no_verify: c.noVerify, + }; +} diff --git a/src/features/vcs/remoteUi.tsx b/src/features/vcs/remoteUi.tsx index c5082c34b..de3a46bce 100644 --- a/src/features/vcs/remoteUi.tsx +++ b/src/features/vcs/remoteUi.tsx @@ -25,6 +25,7 @@ import { openDialog } from "./dialogs"; import { formatTime } from "./model"; import { allRepos, lastRemoteFailure, runRemote, saveAll } from "./ops"; import { PushChanges, pushChangesKey } from "./PushChanges"; +import { pushOperation, splitTracked, type PushChoice } from "./pushTarget"; import { currentRepo, refreshNow, repoName, useVcs } from "./store"; import styles from "./common.module.css"; @@ -242,6 +243,10 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { const [tagsMode, setTagsMode] = useState("all"); const hasHook = usePrePushHook(roots); const [runHooks, setRunHooks] = useState(true); + const [remotesOf, setRemotesOf] = useState>({}); + const [edits, setEdits] = useState>({}); + const edit = (root: string, change: { remote?: string; target?: string }) => + setEdits((prev) => ({ ...prev, [root]: { ...prev[root], ...change } })); useEffect(() => { let alive = true; @@ -249,12 +254,16 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { // branch falls back to recent history (pushRepoCommits) and stays pushable. Promise.allSettled( roots.map(async (root) => { + const remotes = call(commands.vcsRemotes(root)).catch(() => []); const branch = await call(commands.vcsBranchInfo(root)); - return { root, branch, commits: await pushRepoCommits(root, branch) }; + const commits = await pushRepoCommits(root, branch); + return { repo: { root, branch, commits }, remotes: (await remotes).map((r) => r.name) }; }), ).then((results) => { if (!alive) return; - const loaded = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); + const fulfilled = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); + const loaded = fulfilled.map((r) => r.repo); + setRemotesOf(Object.fromEntries(fulfilled.map((r) => [r.repo.root, r.remotes]))); const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); if (!loaded.length && failed) { setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason)); @@ -275,9 +284,28 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { if (!next.delete(root)) next.add(root); return next; }); - const targetOf = (r: RepoPush) => - r.branch.upstream ? r.branch.upstream.slice(r.branch.upstream.indexOf("/") + 1) : r.branch.head!; + const targetOf = (r: RepoPush) => (r.branch.upstream ? splitTracked(r.branch.upstream).branch : r.branch.head!); + const trackedRemoteOf = (r: RepoPush) => (r.branch.upstream ? splitTracked(r.branch.upstream).remote : null); + const shownRemoteOf = (r: RepoPush) => { + const remotes = remotesOf[r.root] ?? []; + return ( + edits[r.root]?.remote ?? trackedRemoteOf(r) ?? (remotes.includes("origin") ? "origin" : (remotes[0] ?? null)) + ); + }; + const targetNameOf = (r: RepoPush) => edits[r.root]?.target?.trim() || targetOf(r); + const choiceOf = (r: RepoPush, tags: PushTags | null): PushChoice => ({ + head: r.branch.head!, + tracked: r.branch.upstream, + remote: edits[r.root]?.remote ?? null, + target: edits[r.root]?.target ?? "", + forceWithLease: force && !isProtectedBranch(targetNameOf(r)), + setUpstream: false, + tags, + noVerify: hasHook && !runHooks, + }); const picked = (repos ?? []).filter((r) => checked.has(r.root)); + // Like the single-repository dialog: tags go only with plain pushes, so a retargeted repository hides them. + const retargeted = picked.some((r) => pushOperation(choiceOf(r, null)).op === "pushBranch"); const push = async () => { onClose(); // Every picked repository is pushed even when another fails; the outcome is summed up. @@ -285,19 +313,20 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { const failed: string[] = []; for (const r of picked) { const upstream = r.branch.upstream; - const ok = await pushWithAutoUpdate( - { - op: "push", - force_with_lease: force && !isProtectedBranch(targetOf(r)), - set_upstream: !upstream, - tags: pushTags ? tagsMode : null, - no_verify: hasHook && !runHooks, - }, - `${repoName(r.root)}: ${r.branch.head} → ${upstream ?? "new remote branch"}`, - r.root, - session, - ); - if (!ok) failed.push(repoName(r.root)); + const op = pushOperation(choiceOf(r, pushTags && !retargeted ? tagsMode : null)); + const name = repoName(r.root); + const remote = shownRemoteOf(r); + // A retargeted push names a branch, so it has no tracked branch to update from when rejected. + const ok = + op.op === "pushBranch" + ? await runRemote(op, `${name}: ${r.branch.head} → ${remote ? `${remote}/` : ""}${targetNameOf(r)}`, [r.root]) + : await pushWithAutoUpdate( + op, + `${name}: ${r.branch.head} → ${upstream ?? "new remote branch"}`, + r.root, + session, + ); + if (!ok) failed.push(name); } const notPushed = failed.join(", "); if (failed.length && picked.length > 1) @@ -331,17 +360,21 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { <> {hasHook && } - - - value={tagsMode} - disabled={!pushTags} - aria-label="Tags to push" - onChange={setTagsMode} - options={[ - { value: "all", label: "All" }, - { value: "currentBranch", label: "Current Branch" }, - ]} - /> + {!retargeted && ( + <> + + + value={tagsMode} + disabled={!pushTags} + aria-label="Tags to push" + onChange={setTagsMode} + options={[ + { value: "all", label: "All" }, + { value: "currentBranch", label: "Current Branch" }, + ]} + /> + + )} } buttons={[ @@ -411,9 +444,20 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { onChange={() => toggle(n.repo.root)} label={repoName(n.repo.root)} /> - - {n.repo.branch.head} → {n.repo.branch.upstream ?? tx("new remote branch")} - + {n.repo.branch.head} → + edit(n.repo.root, { remote })} + target={edits[n.repo.root]?.target ?? targetOf(n.repo)} + onTarget={(target) => edit(n.repo.root, { target })} + changed={pushOperation(choiceOf(n.repo, null)).op === "pushBranch"} + onEnter={() => { + if (picked.length) void push(); + }} + /> + {!n.repo.branch.upstream && {tx("new remote branch")}} ) } @@ -427,6 +471,59 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { ); } +/** The remote and target branch of one repository's row in the multi-repository Push tree. */ +function PushRowTarget(props: { + name: string; + remotes: string[]; + remote: string | null; + onRemote: (remote: string) => void; + target: string; + onTarget: (target: string) => void; + /** Pushed elsewhere than to the upstream: the field is marked so the difference is not missed. */ + changed: boolean; + onEnter: () => void; +}) { + return ( + { + if (e.key !== "Escape" && e.key !== "Tab") e.stopPropagation(); + }} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {props.remotes.length > 1 ? ( + + variant="ghost" + value={props.remote ?? ""} + aria-label={tx`Remote of ${props.name}`} + onChange={props.onRemote} + options={props.remotes.map((r) => ({ value: r, label: r }))} + /> + ) : ( + props.remote && {props.remote} + )} + {props.remote && /} + props.onTarget(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter" || e.nativeEvent.isComposing) return; + e.preventDefault(); + props.onEnter(); + }} + className={styles.pushRowInput} + /> + + ); +} + function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClose: () => void }) { const [repo] = useState(currentRepo); const [branch] = useState(() => useVcs.getState().branch); @@ -515,34 +612,24 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo const push = async () => { if (force && !protectedTarget && !(await confirmForcePush(remote ? `${remote}/${target}` : (target ?? "")))) return; onClose(); - if ((explicit || retarget || remoteChanged) && head) { - await runRemote( - { - op: "pushBranch", - branch: head, - revision, - force_with_lease: force && !protectedTarget, - target: retarget ? edited : null, - remote: remoteChanged ? remote : null, - set_upstream: setUpstream && !revision, - no_verify: hasHook && !runHooks, - }, - `${revision ? `${head} up to ${revision.slice(0, 8)}` : head} → ${targetLabel}`, - [repo], - ); + if (!head) return; + const op = pushOperation({ + head, + tracked, + remote, + target: targetName, + explicit, + revision, + forceWithLease: force && !protectedTarget, + setUpstream, + tags: pushTags ? tagsMode : null, + noVerify: hasHook && !runHooks, + }); + if (op.op === "pushBranch") { + await runRemote(op, `${revision ? `${head} up to ${revision.slice(0, 8)}` : head} → ${targetLabel}`, [repo]); return; } - await pushWithAutoUpdate( - { - op: "push", - force_with_lease: force && !protectedTarget, - set_upstream: setUpstream || !upstream, - tags: pushTags ? tagsMode : null, - no_verify: hasHook && !runHooks, - }, - upstream ? `${head} → ${upstream}` : `${head} → new remote branch`, - repo, - ); + await pushWithAutoUpdate(op, upstream ? `${head} → ${upstream}` : `${head} → new remote branch`, repo); }; const commitList = ( diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 1928d0c15..9126c779b 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -750,5 +750,7 @@ "Choose Gradle JVM": "Choose Gradle JVM", "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.": "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.", "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.": "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.", - "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper." + "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.", + "Remote of {name}": "Remote of {name}", + "Target branch of {name}": "Target branch of {name}" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index db2851250..37b219a6e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7083,5 +7083,7 @@ "Choose Gradle JVM": "选择 Gradle JVM", "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.": "请在 Gradle JVM 中选择与此 Gradle 版本兼容的 JDK。JVM 选择器中提供下载 JDK 选项。", "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.": "无法启动 Maven。请在 Maven 设置中配置 Maven 主目录,将 Maven 安装到 PATH,或使用带有 Maven wrapper 的项目。", - "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "无法启动 Gradle。请在 Gradle 设置中配置本地安装,将 Gradle 安装到 PATH,或使用带有 Gradle wrapper 的项目。" + "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "无法启动 Gradle。请在 Gradle 设置中配置本地安装,将 Gradle 安装到 PATH,或使用带有 Gradle wrapper 的项目。", + "Remote of {name}": "{name} 的远程", + "Target branch of {name}": "{name} 的目标分支" }