2b5d7268ed
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease, remote-branch-delete, remote add/rename/remove) and git-conflicts.js (conflict stages read + conflict-save, reflog), dispatched from runGitAction; operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes, /git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView, RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog, rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
/**
|
|
* @file RemotesDialog.tsx
|
|
* @description The repo's remotes: each one's URL (no password is ever
|
|
* shown — the server strips it) and default branch; add one (name + an
|
|
* https:// with no login, ssh:// or user@host:path URL — the server's
|
|
* allowlist), rename one, remove one (two clicks), and delete a branch ON a
|
|
* remote (two clicks; never its default branch nor main/master/develop —
|
|
* the server refuses those whatever this shows).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { GitTarget } from "../../../lib/api";
|
|
import { errText, gitRewrite, type GitRemote } from "../../../lib/gitRewrite";
|
|
import { toast } from "../../../lib/toast";
|
|
import { useArmed } from "../../../hooks/useArmed";
|
|
import { BTN, BTN_DANGER, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
|
|
|
|
const PROTECTED = new Set(["main", "master", "development", "develop"]);
|
|
|
|
export function RemotesDialog({
|
|
target,
|
|
onClose,
|
|
onChanged,
|
|
}: {
|
|
target: GitTarget;
|
|
onClose: () => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["gitTools"]);
|
|
const [remotes, setRemotes] = useState<GitRemote[] | null>(null);
|
|
const [name, setName] = useState("");
|
|
const [url, setUrl] = useState("");
|
|
const [renaming, setRenaming] = useState<{ from: string; to: string } | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const { isArmed, arm, disarm } = useArmed();
|
|
const key = JSON.stringify(target);
|
|
|
|
const load = useCallback(() => {
|
|
gitRewrite
|
|
.remotes(target)
|
|
.then((r) => setRemotes(r.remotes))
|
|
.catch((e) => setError(errText(e)));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key]);
|
|
useEffect(load, [load]);
|
|
|
|
const act = async (fn: () => Promise<unknown>, ok: string) => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await fn();
|
|
toast.success(ok);
|
|
onChanged();
|
|
load();
|
|
return true;
|
|
} catch (e) {
|
|
setError(errText(e));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const add = async () => {
|
|
const n = name.trim();
|
|
const u = url.trim();
|
|
if (
|
|
await act(
|
|
() => gitRewrite.run(target, "remote-add", { name: n, url: u }),
|
|
t("remotes.added", { name: n })
|
|
)
|
|
) {
|
|
setName("");
|
|
setUrl("");
|
|
}
|
|
};
|
|
const rename = async () => {
|
|
if (!renaming) return;
|
|
const { from, to } = renaming;
|
|
if (
|
|
await act(
|
|
() => gitRewrite.run(target, "remote-rename", { name: from, to: to.trim() }),
|
|
t("remotes.renamed", { from, to: to.trim() })
|
|
)
|
|
)
|
|
setRenaming(null);
|
|
};
|
|
const remove = (n: string) => {
|
|
if (!isArmed(`rm:${n}`)) return arm(`rm:${n}`);
|
|
disarm();
|
|
void act(
|
|
() => gitRewrite.run(target, "remote-remove", { name: n, confirm: true }),
|
|
t("remotes.removed", { name: n })
|
|
);
|
|
};
|
|
const deleteBranch = (ref: string) => {
|
|
if (!isArmed(`del:${ref}`)) return arm(`del:${ref}`);
|
|
disarm();
|
|
void act(
|
|
() => gitRewrite.run(target, "remote-branch-delete", { branch: ref, confirm: true }),
|
|
t("remotes.branchDeleted", { ref })
|
|
);
|
|
};
|
|
|
|
return (
|
|
<GitModal title={t("remotes.title")} onClose={onClose} testId="remotes-dialog">
|
|
{remotes == null ? (
|
|
!error && <p className="text-fg-muted">{t("loading")}</p>
|
|
) : !remotes.length ? (
|
|
<p className="mb-2">{t("remotes.none")}</p>
|
|
) : (
|
|
<ul data-testid="remotes-list" className="mb-3 space-y-2">
|
|
{remotes.map((r) => (
|
|
<li key={r.name} className="rounded border border-border-light p-1.5">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{renaming?.from === r.name ? (
|
|
<input
|
|
aria-label={t("remotes.newName")}
|
|
data-testid="remote-rename-input"
|
|
value={renaming.to}
|
|
onChange={(e) => setRenaming({ from: r.name, to: e.target.value })}
|
|
className="input min-w-0 flex-1 py-0.5 font-mono text-[11px]"
|
|
/>
|
|
) : (
|
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold text-fg-primary">
|
|
{r.name}
|
|
</span>
|
|
)}
|
|
{renaming?.from === r.name ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={BTN_PRIMARY}
|
|
disabled={busy || !renaming.to.trim()}
|
|
onClick={() => void rename()}
|
|
>
|
|
{t("remotes.rename")}
|
|
</button>
|
|
<button type="button" className={BTN} onClick={() => setRenaming(null)}>
|
|
{t("cancel")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
disabled={busy}
|
|
onClick={() => setRenaming({ from: r.name, to: r.name })}
|
|
>
|
|
{t("remotes.rename")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid={`remote-remove-${r.name}`}
|
|
className={BTN_DANGER}
|
|
disabled={busy}
|
|
onClick={() => remove(r.name)}
|
|
>
|
|
{isArmed(`rm:${r.name}`) ? t("remotes.removeConfirm") : t("remotes.remove")}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<p className="mt-0.5 break-all font-mono text-[10px] text-fg-muted">{r.url}</p>
|
|
{r.defaultBranch && (
|
|
<p className="text-[10px] text-fg-muted">
|
|
{t("remotes.default", { branch: r.defaultBranch })}
|
|
</p>
|
|
)}
|
|
{!!r.branches?.length && (
|
|
<details className="mt-1">
|
|
<summary className="cursor-pointer text-[11px]">
|
|
{t("remotes.branches", { count: r.branches.length })}
|
|
</summary>
|
|
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto">
|
|
{r.branches.map((b) => {
|
|
const ref = `${r.name}/${b}`;
|
|
const locked = PROTECTED.has(b) || b === r.defaultBranch;
|
|
return (
|
|
<li key={b} className="flex items-center gap-1.5">
|
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{b}</span>
|
|
<button
|
|
type="button"
|
|
data-testid={`remote-branch-delete-${ref}`}
|
|
className={BTN_DANGER}
|
|
disabled={busy || locked}
|
|
title={locked ? t("remotes.branchLocked") : undefined}
|
|
onClick={() => deleteBranch(ref)}
|
|
>
|
|
{isArmed(`del:${ref}`)
|
|
? t("remotes.deleteBranchConfirm")
|
|
: t("remotes.deleteBranch")}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</details>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void add();
|
|
}}
|
|
className="space-y-1.5 rounded border border-border p-1.5"
|
|
>
|
|
<p className="text-[11px] font-medium text-fg-primary">{t("remotes.add")}</p>
|
|
<input
|
|
aria-label={t("remotes.name")}
|
|
placeholder={t("remotes.name")}
|
|
data-testid="remote-add-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="input w-full font-mono text-[11px]"
|
|
/>
|
|
<input
|
|
aria-label={t("remotes.url")}
|
|
placeholder="https://host/owner/repo.git"
|
|
data-testid="remote-add-url"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
className="input w-full font-mono text-[11px]"
|
|
/>
|
|
<p className="text-[10px] text-fg-muted">{t("remotes.urlHint")}</p>
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
data-testid="remote-add-submit"
|
|
className={BTN_PRIMARY}
|
|
disabled={busy || !name.trim() || !url.trim()}
|
|
>
|
|
{t("remotes.addButton")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<ErrorLine error={error} />
|
|
</GitModal>
|
|
);
|
|
}
|