дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(vcs): pick each repository's remote and target branch in the multi-repository Push dialog (#2342)
Co-authored-by: NguyenBang <[email protected]>
Цей коміт міститься в:
1 джерело
d02aae2280
коміт
039ec09122
9 files changed
+445
-60
No files matched your search
@@ -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<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage: (line: string) => void = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
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(<VcsDialogHost />);
|
||||
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.");
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<RemoteOp, { op: "push" | "pushBranch" }>;
|
||||
|
||||
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 <remote> <src>:<dst>`
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -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<PushTags>("all");
|
||||
const hasHook = usePrePushHook(roots);
|
||||
const [runHooks, setRunHooks] = useState(true);
|
||||
const [remotesOf, setRemotesOf] = useState<Record<string, string[]>>({});
|
||||
const [edits, setEdits] = useState<Record<string, { remote?: string; target?: string }>>({});
|
||||
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 }) {
|
||||
<>
|
||||
<Checkbox checked={force} onChange={setForce} label="Force (with lease)" />
|
||||
{hasHook && <Checkbox checked={runHooks} onChange={setRunHooks} label="Run Git hooks" />}
|
||||
<Checkbox checked={pushTags} onChange={setPushTags} label="Push tags:" />
|
||||
<Select<PushTags>
|
||||
value={tagsMode}
|
||||
disabled={!pushTags}
|
||||
aria-label="Tags to push"
|
||||
onChange={setTagsMode}
|
||||
options={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "currentBranch", label: "Current Branch" },
|
||||
]}
|
||||
/>
|
||||
{!retargeted && (
|
||||
<>
|
||||
<Checkbox checked={pushTags} onChange={setPushTags} label="Push tags:" />
|
||||
<Select<PushTags>
|
||||
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)}
|
||||
/>
|
||||
<span className={styles.commitMeta}>
|
||||
{n.repo.branch.head} → {n.repo.branch.upstream ?? tx("new remote branch")}
|
||||
</span>
|
||||
<span className={styles.commitMeta}>{n.repo.branch.head} →</span>
|
||||
<PushRowTarget
|
||||
name={repoName(n.repo.root)}
|
||||
remotes={remotesOf[n.repo.root] ?? []}
|
||||
remote={shownRemoteOf(n.repo)}
|
||||
onRemote={(remote) => 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 && <span className={styles.commitMeta}>{tx("new remote branch")}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={styles.pushRowTarget}
|
||||
// The tree would take typed keys for speed search and navigation, and a click for toggling the row.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape" && e.key !== "Tab") e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{props.remotes.length > 1 ? (
|
||||
<Select<string>
|
||||
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 && <span className={styles.commitMeta}>{props.remote}</span>
|
||||
)}
|
||||
{props.remote && <span className={styles.pushRowSeparator}>/</span>}
|
||||
<Input
|
||||
aria-label={tx`Target branch of ${props.name}`}
|
||||
mono
|
||||
value={props.target}
|
||||
data-changed={props.changed || undefined}
|
||||
// Mono, so `ch` is exact: the field follows the branch name instead of a fixed width.
|
||||
style={{ width: `${Math.min(32, Math.max(10, props.target.length + 3))}ch` }}
|
||||
onChange={(e) => props.onTarget(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
|
||||
e.preventDefault();
|
||||
props.onEnter();
|
||||
}}
|
||||
className={styles.pushRowInput}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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} 的目标分支"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача