feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
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.
This commit is contained in:
@@ -150,6 +150,7 @@ import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
@@ -869,6 +870,8 @@ export function LaneChangesPanel({
|
||||
const opOpen = !!(git?.available && git.merging);
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session, source);
|
||||
// The open history-rewrite / remote / merge-editor tool (./git/GitTools).
|
||||
const [gitTool, setGitTool] = useState<GitTool | null>(null);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
@@ -1881,10 +1884,38 @@ export function LaneChangesPanel({
|
||||
{t("changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* History rewrites (./git): both open a dialog that asks first. */}
|
||||
<MenuItem
|
||||
disabled={busy || opOpen}
|
||||
testId="lane-changes-menu-reset"
|
||||
onSelect={pick(commitCtx.close, () =>
|
||||
setGitTool({ kind: "reset", commit: commitCtx.menu!.item })
|
||||
)}
|
||||
>
|
||||
{t("gitTools:menu.resetHere")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commits?.[0]?.sha === commitCtx.menu.item.sha}
|
||||
testId="lane-changes-menu-interactive"
|
||||
onSelect={pick(commitCtx.close, () =>
|
||||
setGitTool({ kind: "interactive", base: commitCtx.menu!.item.sha })
|
||||
)}
|
||||
>
|
||||
{t("gitTools:menu.interactiveHere")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
<GitTools
|
||||
tool={gitTool}
|
||||
target={target}
|
||||
branch={git?.available ? git.branch : null}
|
||||
onClose={() => setGitTool(null)}
|
||||
onOpen={setGitTool}
|
||||
onChanged={() => void rereadAll()}
|
||||
onViewCommit={viewCommit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2484,6 +2515,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!notRepo && (
|
||||
<GitToolsButton
|
||||
onPick={setGitTool}
|
||||
hasRemote={git?.available ? git.hasRemote : undefined}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runReread}
|
||||
@@ -2710,6 +2747,13 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
{git.operation === "rebase" && (
|
||||
<RebaseSkipButton
|
||||
target={target}
|
||||
disabled={busy}
|
||||
onChanged={() => void rereadAll()}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
@@ -3114,6 +3158,9 @@ export function LaneChangesPanel({
|
||||
editWanted.current = true;
|
||||
setWhole(true);
|
||||
}}
|
||||
onMergeEditor={() =>
|
||||
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
|
||||
}
|
||||
/>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) || openImage ? (
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
|
||||
* `resolve done`, which re-checks that no marker is left. Take all mine /
|
||||
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
|
||||
* file to the panel's plain editor.
|
||||
* file to the panel's plain editor; Merge editor opens the 3-way editor
|
||||
* (`./git/MergeEditor`, base/current/incoming side by side).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -42,7 +43,10 @@ export function LaneConflictView({
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
onMergeEditor,
|
||||
}: {
|
||||
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
|
||||
onMergeEditor?: () => void;
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
@@ -176,6 +180,17 @@ export function LaneConflictView({
|
||||
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
||||
{t("conflict.editByHand")}
|
||||
</button>
|
||||
{onMergeEditor && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="conflict-open-merge-editor"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={onMergeEditor}
|
||||
>
|
||||
{t("gitTools:merge.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file ForcePushDialog.tsx
|
||||
* @description "Force push (with lease)" for the CURRENT branch only: shows
|
||||
* where it goes (remote and branch), the remote commit it will replace (the
|
||||
* lease — the server refuses with ESTALE if the remote moved since the last
|
||||
* fetch), how far ahead/behind HEAD is, and refuses a protected branch unless
|
||||
* the server allows it. Push is a two-click (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
|
||||
export function ForcePushDialog({
|
||||
target,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<PushPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planPush(target)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
if (!isArmed("push")) return arm("push");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true });
|
||||
toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch }));
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("forcePush.title")}
|
||||
onClose={onClose}
|
||||
testId="force-push-dialog"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="force-push-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || !plan || !plan.allowed}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{isArmed("push") ? t("forcePush.runConfirm") : t("forcePush.run")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p data-testid="force-push-where" className="break-words">
|
||||
{t("forcePush.where", {
|
||||
branch: plan.branch,
|
||||
remote: plan.remote,
|
||||
remoteBranch: plan.remoteBranch,
|
||||
})}
|
||||
</p>
|
||||
<p className="break-words">
|
||||
{plan.remoteSha
|
||||
? t("forcePush.replaces", {
|
||||
sha: plan.remoteSha.slice(0, 7),
|
||||
ahead: plan.ahead,
|
||||
behind: plan.behind,
|
||||
})
|
||||
: t("forcePush.newBranch")}
|
||||
</p>
|
||||
{plan.behind > 0 && <Warn>{t("forcePush.dropsRemote", { count: plan.behind })}</Warn>}
|
||||
{!plan.allowed && <Warn testId="force-push-protected">{t("forcePush.protected")}</Warn>}
|
||||
<p className="text-[11px] text-fg-muted">{t("forcePush.lease")}</p>
|
||||
<ErrorLine error={error} />
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file GitModal.tsx
|
||||
* @description The shared modal shell of the Changes panel's git tools (merge
|
||||
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
|
||||
* portal over the screen with `useDialog`'s focus contract, a title row with a
|
||||
* close button, a scrolling body and an optional footer. `wide` widens it for
|
||||
* the three-pane merge editor; on a phone every size is the full width.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../../../hooks/useDialog";
|
||||
|
||||
export const BTN =
|
||||
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const BTN_PRIMARY =
|
||||
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const BTN_DANGER =
|
||||
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
export function GitModal({
|
||||
title,
|
||||
onClose,
|
||||
testId,
|
||||
wide = false,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
wide?: boolean;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDialog(true, panel, onClose);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid={testId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
|
||||
wide ? "max-w-6xl" : "max-w-lg"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("close")}
|
||||
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2 text-xs text-fg-secondary">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** A red inline error line, or nothing. */
|
||||
export function ErrorLine({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** An amber explanation box shown before a rewrite runs. */
|
||||
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @file GitTools.tsx
|
||||
* @description The Changes panel's entry points to the history-rewrite and
|
||||
* remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive
|
||||
* rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool
|
||||
* is open, so the panel holds one `GitTool | null` state), and
|
||||
* `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the
|
||||
* banner's own Complete/Abort, which the server maps onto the rebase).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { History } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu";
|
||||
import { ForcePushDialog } from "./ForcePushDialog";
|
||||
import { InteractiveRebase } from "./InteractiveRebase";
|
||||
import { MergeEditor } from "./MergeEditor";
|
||||
import { RebaseDialog } from "./RebaseDialog";
|
||||
import { ReflogView } from "./ReflogView";
|
||||
import { RemotesDialog } from "./RemotesDialog";
|
||||
import { ResetDialog } from "./ResetDialog";
|
||||
|
||||
export type GitTool =
|
||||
| { kind: "rebase" }
|
||||
| { kind: "interactive"; base?: string }
|
||||
| { kind: "reset"; commit: { sha: string; short?: string; subject?: string } }
|
||||
| { kind: "reflog" }
|
||||
| { kind: "force-push" }
|
||||
| { kind: "remotes" }
|
||||
| { kind: "merge"; path: string; onResolved?: () => void };
|
||||
|
||||
export function GitToolsButton({
|
||||
onPick,
|
||||
disabled,
|
||||
hasRemote,
|
||||
}: {
|
||||
onPick: (tool: GitTool) => void;
|
||||
disabled?: boolean;
|
||||
hasRemote?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const pick = (tool: GitTool) => () => {
|
||||
setAt(null);
|
||||
onPick(tool);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-testid="git-tools-button"
|
||||
aria-label={t("menu.label")}
|
||||
title={t("menu.label")}
|
||||
aria-haspopup="menu"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
setAt(r ? { x: r.left, y: r.bottom + 2 } : { x: 0, y: 0 });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{at && (
|
||||
<ContextMenu x={at.x} y={at.y} onClose={() => setAt(null)} testId="git-tools-menu">
|
||||
<MenuItem testId="git-tools-rebase" onSelect={pick({ kind: "rebase" })}>
|
||||
{t("menu.rebase")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-interactive" onSelect={pick({ kind: "interactive" })}>
|
||||
{t("menu.interactive")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-reflog" onSelect={pick({ kind: "reflog" })}>
|
||||
{t("menu.reflog")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
testId="git-tools-force-push"
|
||||
danger
|
||||
disabled={hasRemote === false}
|
||||
onSelect={pick({ kind: "force-push" })}
|
||||
>
|
||||
{t("menu.forcePush")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-remotes" onSelect={pick({ kind: "remotes" })}>
|
||||
{t("menu.remotes")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitTools({
|
||||
tool,
|
||||
target,
|
||||
branch,
|
||||
onClose,
|
||||
onOpen,
|
||||
onChanged,
|
||||
onViewCommit,
|
||||
}: {
|
||||
tool: GitTool | null;
|
||||
target: GitTarget;
|
||||
/** The current branch, for titles. */
|
||||
branch: string | null;
|
||||
onClose: () => void;
|
||||
/** Open another tool (the reflog's "Reset here"). */
|
||||
onOpen: (tool: GitTool) => void;
|
||||
/** Re-read the panel's git facts after a write. */
|
||||
onChanged: () => void;
|
||||
onViewCommit?: (sha: string) => void;
|
||||
}) {
|
||||
if (!tool) return null;
|
||||
switch (tool.kind) {
|
||||
case "rebase":
|
||||
return (
|
||||
<RebaseDialog target={target} current={branch} onClose={onClose} onChanged={onChanged} />
|
||||
);
|
||||
case "interactive":
|
||||
return (
|
||||
<InteractiveRebase
|
||||
target={target}
|
||||
base={tool.base}
|
||||
onClose={onClose}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
case "reset":
|
||||
return (
|
||||
<ResetDialog target={target} commit={tool.commit} onClose={onClose} onChanged={onChanged} />
|
||||
);
|
||||
case "reflog":
|
||||
return (
|
||||
<ReflogView
|
||||
target={target}
|
||||
onClose={onClose}
|
||||
onViewCommit={
|
||||
onViewCommit &&
|
||||
((sha) => {
|
||||
onClose();
|
||||
onViewCommit(sha);
|
||||
})
|
||||
}
|
||||
onReset={(e) =>
|
||||
onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } })
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "force-push":
|
||||
return <ForcePushDialog target={target} onClose={onClose} onChanged={onChanged} />;
|
||||
case "remotes":
|
||||
return <RemotesDialog target={target} onClose={onClose} onChanged={onChanged} />;
|
||||
case "merge":
|
||||
return (
|
||||
<MergeEditor
|
||||
target={target}
|
||||
path={tool.path}
|
||||
onClose={onClose}
|
||||
onResolved={() => {
|
||||
onChanged();
|
||||
tool.onResolved?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rebase banner's Skip: drops the commit the rebase stopped on. */
|
||||
export function RebaseSkipButton({
|
||||
target,
|
||||
disabled,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
disabled?: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rebase-skip"
|
||||
title={t("rebaseBanner.skipHint")}
|
||||
disabled={disabled || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase-skip", {});
|
||||
if (r.conflicts?.length) toast.error(t("rebase.stopped", { count: r.conflicts.length }));
|
||||
else if (!r.stopped) toast.success(t("rebaseBanner.finished"));
|
||||
} catch (e) {
|
||||
toast.error(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
onChanged();
|
||||
}
|
||||
}}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("rebaseBanner.skip")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @file InteractiveRebase.tsx
|
||||
* @description Interactive rebase as a list (JetBrains' "Interactively Rebase
|
||||
* from Here"): the branch's commits since a base — by default the ones no
|
||||
* remote has yet, oldest first — each with Pick / Reword / Squash / Fixup /
|
||||
* Drop, moved up and down, a new message for a reword or a squash. Before it
|
||||
* runs it says what it rewrites ("This rewrites 4 commits; 2 are already
|
||||
* pushed, so a force-push will be needed") and pushed commits need an explicit
|
||||
* tick; Start is a two-click (`useArmed`). The server builds the todo and runs
|
||||
* `git rebase -i` with no editor; a clean run stays open with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import {
|
||||
errText,
|
||||
gitRewrite,
|
||||
type InteractivePlan,
|
||||
type PlanCommit,
|
||||
type RewriteResult,
|
||||
type TodoAction,
|
||||
} from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
import { blockedReason } from "./RebaseDialog";
|
||||
|
||||
const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"];
|
||||
|
||||
interface Row {
|
||||
commit: PlanCommit;
|
||||
action: TodoAction;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Why the list cannot run as it stands, or null. */
|
||||
export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null {
|
||||
const kept = rows.filter((r) => r.action !== "drop");
|
||||
if (!kept.length) return "allDropped";
|
||||
if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first";
|
||||
if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function InteractiveRebase({
|
||||
target,
|
||||
base,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** Rewrite the commits after this one; absent = the unpushed ones. */
|
||||
base?: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<InteractivePlan | null>(null);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [force, setForce] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planInteractive(target, base)
|
||||
.then((p) => {
|
||||
if (!live) return;
|
||||
setPlan(p);
|
||||
setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message })));
|
||||
})
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, base]);
|
||||
|
||||
const set = (i: number, patch: Partial<Row>) =>
|
||||
setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r)));
|
||||
const move = (i: number, step: number) =>
|
||||
setRows((prev) => {
|
||||
const j = i + step;
|
||||
if (j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[i], next[j]] = [next[j]!, next[i]!];
|
||||
return next;
|
||||
});
|
||||
|
||||
const blocked = plan ? blockedReason(t, plan) : null;
|
||||
const problem = todoProblem(rows);
|
||||
const unchanged =
|
||||
!!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha);
|
||||
const pushed = plan?.pushed ?? 0;
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
if (!isArmed("run")) return arm("run");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase-interactive", {
|
||||
head: plan.head,
|
||||
...(base ? { base: plan.base } : {}),
|
||||
confirm: true,
|
||||
...(pushed ? { force: true } : {}),
|
||||
todo: rows.map((row) => ({
|
||||
sha: row.commit.sha,
|
||||
action: row.action,
|
||||
...((row.action === "reword" || row.action === "squash") &&
|
||||
row.message.trim() &&
|
||||
row.message.trim() !== row.commit.message
|
||||
? { message: row.message }
|
||||
: row.action === "reword"
|
||||
? { message: row.message }
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
onChanged();
|
||||
if (r.conflicts?.length || r.stopped) {
|
||||
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success(t("interactive.done"));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("interactive.title")}
|
||||
onClose={onClose}
|
||||
testId="interactive-rebase"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="interactive-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={
|
||||
busy || !plan || !!blocked || !!problem || unchanged || (pushed > 0 && !force)
|
||||
}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{isArmed("run") ? t("interactive.runConfirm") : t("interactive.run")}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : !plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-1.5 text-[11px] text-fg-muted">{t("interactive.explain")}</p>
|
||||
{blocked && <Warn>{blocked}</Warn>}
|
||||
{plan.merges && <Warn>{t("interactive.merges")}</Warn>}
|
||||
{!rows.length ? (
|
||||
<p data-testid="interactive-empty">{t("interactive.nothing")}</p>
|
||||
) : (
|
||||
<>
|
||||
<Warn testId="interactive-summary">
|
||||
{pushed
|
||||
? t("rewritesPushed", { count: rows.length, pushed })
|
||||
: t("rewrites", { count: rows.length })}
|
||||
{pushed > 0 && (
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="interactive-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
)}
|
||||
</Warn>
|
||||
<ol data-testid="interactive-list" className="space-y-1">
|
||||
{rows.map((r, i) => (
|
||||
<li
|
||||
key={r.commit.sha}
|
||||
data-testid="interactive-row"
|
||||
className={`rounded border border-border-light p-1.5 ${
|
||||
r.action === "drop" ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("interactive.action", { short: r.commit.short })}
|
||||
data-testid="interactive-action"
|
||||
value={r.action}
|
||||
onChange={(e) => set(i, { action: e.target.value as TodoAction })}
|
||||
className="input py-0.5 text-[11px]"
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{t(`interactive.actions.${a}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{r.commit.short}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-[11px] text-fg-primary ${
|
||||
r.action === "drop" ? "line-through" : ""
|
||||
}`}
|
||||
title={r.commit.subject}
|
||||
>
|
||||
{r.commit.subject}
|
||||
</span>
|
||||
{r.commit.pushed && (
|
||||
<span className="shrink-0 text-[10px] text-status-warning">
|
||||
{t("pushed")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("interactive.up")}
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("interactive.down")}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{(r.action === "reword" || r.action === "squash") && (
|
||||
<textarea
|
||||
data-testid="interactive-message"
|
||||
aria-label={t("interactive.message")}
|
||||
value={r.message}
|
||||
rows={3}
|
||||
onChange={(e) => set(i, { message: e.target.value })}
|
||||
placeholder={
|
||||
r.action === "squash" ? t("interactive.squashHint") : undefined
|
||||
}
|
||||
className="input mt-1 w-full font-mono text-[11px]"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{problem && (
|
||||
<p
|
||||
data-testid="interactive-problem"
|
||||
className="mt-1 text-[11px] text-status-danger"
|
||||
>
|
||||
{t(`interactive.problem.${problem}`)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* @file MergeEditor.tsx
|
||||
* @description The 3-way merge editor for ONE conflicted file — a merge,
|
||||
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
|
||||
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
|
||||
* base (stage :1) one click away. Each conflict block of the result takes
|
||||
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
|
||||
* undoable; next/previous conflict and "N conflicts left"; or the whole result
|
||||
* edited as text. Save writes the result through the `conflict-save` action —
|
||||
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
|
||||
* then `git add` — which the server refuses while a marker is left.
|
||||
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
|
||||
* commit being replayed, so the labels say that rather than "mine".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
|
||||
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
export type BlockChoice =
|
||||
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
|
||||
|
||||
type Block = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
|
||||
/** The lines a block resolves to, or null while it is unresolved. */
|
||||
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
|
||||
if (!c) return null;
|
||||
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
|
||||
if (c.kind === "ours") return p.ours;
|
||||
if (c.kind === "theirs") return p.theirs;
|
||||
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
|
||||
return [...p.theirs, ...p.ours];
|
||||
}
|
||||
|
||||
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
|
||||
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
for (const p of parts) {
|
||||
if (p.kind === "text") {
|
||||
out.push(...p.lines);
|
||||
continue;
|
||||
}
|
||||
const lines = blockLines(p, choices[i++]);
|
||||
if (lines) out.push(...lines);
|
||||
else
|
||||
out.push(
|
||||
`<<<<<<< ${p.oursLabel}`.trimEnd(),
|
||||
...p.ours,
|
||||
"=======",
|
||||
...p.theirs,
|
||||
`>>>>>>> ${p.theirsLabel}`.trimEnd()
|
||||
);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function Pane({
|
||||
title,
|
||||
text,
|
||||
testId,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
text: string | null;
|
||||
testId: string;
|
||||
tone: "incoming" | "current" | "base";
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const head =
|
||||
tone === "incoming"
|
||||
? "bg-accent/20 text-accent"
|
||||
: tone === "current"
|
||||
? "bg-status-success/20 text-status-success"
|
||||
: "bg-surface-3 text-fg-secondary";
|
||||
return (
|
||||
<section
|
||||
data-testid={testId}
|
||||
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
|
||||
>
|
||||
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
|
||||
{title}
|
||||
</h3>
|
||||
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
|
||||
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
|
||||
</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MergeEditor({
|
||||
target,
|
||||
path,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
onClose: () => void;
|
||||
/** The file was saved and marked resolved. */
|
||||
onResolved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [sides, setSides] = useState<ConflictSides | null>(null);
|
||||
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
|
||||
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
|
||||
const [whole, setWhole] = useState<string | null>(null);
|
||||
const [showBase, setShowBase] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [at, setAt] = useState(0);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
let live = true;
|
||||
setSides(null);
|
||||
setChoices([]);
|
||||
setEditing(null);
|
||||
setWhole(null);
|
||||
setError(null);
|
||||
gitRewrite
|
||||
.conflict(target, path)
|
||||
.then((s) => live && setSides(s))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `target` is rebuilt by the caller on every render; its parts are stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(target), path]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const parts =
|
||||
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
|
||||
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
|
||||
const total = blocks.length;
|
||||
const left = blocks.filter((_, i) => !choices[i]).length;
|
||||
|
||||
const rebase = sides?.operation === "rebase";
|
||||
const incomingLabel = sides?.incoming
|
||||
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
|
||||
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
|
||||
})
|
||||
: t("merge.incomingPlain");
|
||||
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
|
||||
branch: sides?.branch ?? "HEAD",
|
||||
});
|
||||
|
||||
const choose = (i: number, c: BlockChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = c;
|
||||
return next;
|
||||
});
|
||||
|
||||
const jump = (step: number) => {
|
||||
if (!total) return;
|
||||
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
|
||||
const pool = open.length ? open : blocks.map((_, i) => i);
|
||||
const from = pool.findIndex((i) => i > at);
|
||||
let i: number;
|
||||
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
|
||||
else {
|
||||
const before = pool.filter((x) => x < at);
|
||||
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
|
||||
}
|
||||
setAt(i);
|
||||
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!sides?.sha || !parts) return;
|
||||
const content = whole ?? composeResult(parts, choices);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
|
||||
toast.success(t("merge.saved", { path }));
|
||||
onResolved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
setError(
|
||||
code === "EMARKERS"
|
||||
? t("merge.markersLeft")
|
||||
: code === "ESTALE"
|
||||
? t("merge.stale")
|
||||
: errText(e)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
|
||||
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
let blockIndex = -1;
|
||||
const result = parts && (
|
||||
<section
|
||||
data-testid="merge-result"
|
||||
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
|
||||
>
|
||||
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
|
||||
{t("merge.result")}
|
||||
</h3>
|
||||
{whole != null ? (
|
||||
<textarea
|
||||
data-testid="merge-whole"
|
||||
aria-label={t("merge.result")}
|
||||
value={whole}
|
||||
onChange={(e) => setWhole(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
|
||||
{parts.map((p, n) => {
|
||||
if (p.kind === "text")
|
||||
return (
|
||||
<pre
|
||||
key={n}
|
||||
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
|
||||
>
|
||||
{p.lines.length ? p.lines.join("\n") : " "}
|
||||
</pre>
|
||||
);
|
||||
const i = ++blockIndex;
|
||||
const c = choices[i];
|
||||
const lines = blockLines(p, c);
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
ref={(el) => {
|
||||
blockRefs.current[i] = el;
|
||||
}}
|
||||
data-testid="merge-block"
|
||||
className={`my-1 border-y-2 ${
|
||||
c ? "border-status-success/60" : "border-status-warning/70"
|
||||
} ${at === i ? "ring-1 ring-accent" : ""}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
|
||||
<span className="mr-auto text-fg-muted">
|
||||
{t("merge.blockOf", { n: i + 1, total })}
|
||||
</span>
|
||||
{editing?.i === i ? (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => {
|
||||
choose(i, { kind: "custom", text: editing.text });
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
{t("merge.editDone")}
|
||||
</button>
|
||||
) : c ? (
|
||||
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
||||
{t("merge.undoChoice")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "ours" },
|
||||
t("merge.acceptCurrent"),
|
||||
"merge-accept-current"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "theirs" },
|
||||
t("merge.acceptIncoming"),
|
||||
"merge-accept-incoming"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "ours-theirs" },
|
||||
t("merge.bothCurrentFirst"),
|
||||
"merge-both-ct"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "theirs-ours" },
|
||||
t("merge.bothIncomingFirst"),
|
||||
"merge-both-ic"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editing?.i !== i && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-edit-block"
|
||||
className={BTN}
|
||||
onClick={() =>
|
||||
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
|
||||
}
|
||||
>
|
||||
{t("merge.editBlock")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing?.i === i ? (
|
||||
<textarea
|
||||
data-testid="merge-block-text"
|
||||
aria-label={t("merge.editBlock")}
|
||||
value={editing.text}
|
||||
onChange={(e) => setEditing({ i, text: e.target.value })}
|
||||
spellCheck={false}
|
||||
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
|
||||
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
|
||||
/>
|
||||
) : lines ? (
|
||||
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{lines.length ? lines.join("\n") : " "}
|
||||
</pre>
|
||||
) : (
|
||||
<>
|
||||
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{p.ours.length ? p.ours.join("\n") : " "}
|
||||
</pre>
|
||||
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{p.theirs.length ? p.theirs.join("\n") : " "}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
wide
|
||||
testId="merge-editor"
|
||||
title={t("merge.title", { path })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<ErrorLine error={error} />
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-save"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("merge.save")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!sides ? (
|
||||
!error && <p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !sides.usable || !parts ? (
|
||||
<p data-testid="merge-unusable" className="text-status-warning">
|
||||
{sides.binary
|
||||
? t("merge.binary")
|
||||
: sides.tooBig
|
||||
? t("merge.tooBig")
|
||||
: sides.working == null
|
||||
? t("merge.noWorking")
|
||||
: t("merge.unbalanced")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
data-testid="merge-left"
|
||||
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
|
||||
>
|
||||
{whole != null
|
||||
? t("merge.wholeMode")
|
||||
: left
|
||||
? t("merge.left", { count: left, total })
|
||||
: t("merge.allChosen")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("merge.prev")}
|
||||
disabled={!total || whole != null}
|
||||
onClick={() => jump(-1)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("merge.next")}
|
||||
disabled={!total || whole != null}
|
||||
onClick={() => jump(1)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<label className="ml-auto flex items-center gap-1 text-[11px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showBase}
|
||||
onChange={(e) => setShowBase(e.target.checked)}
|
||||
/>
|
||||
{t("merge.showBase")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-whole-toggle"
|
||||
className={BTN}
|
||||
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
|
||||
>
|
||||
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
|
||||
</button>
|
||||
</div>
|
||||
{showBase && (
|
||||
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
|
||||
)}
|
||||
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
|
||||
<Pane
|
||||
title={incomingLabel}
|
||||
text={sides.theirs}
|
||||
testId="merge-incoming"
|
||||
tone="incoming"
|
||||
/>
|
||||
{result}
|
||||
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @file RebaseDialog.tsx
|
||||
* @description "Rebase current branch onto…": pick a local or fetched remote
|
||||
* branch, see the plan before anything runs (GET /git/rewrite-plan
|
||||
* kind=rebase — how many commits are replayed, how many are already pushed and
|
||||
* so will need a force-push, how far behind the branch is), then `rebase`.
|
||||
* A conflict closes the dialog: the Changes panel's banner then offers
|
||||
* Continue / Skip / Abort and the merge editor resolves the files. A clean
|
||||
* rebase stays open on its result with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
|
||||
/** Why a rewrite cannot start, from the plan's common facts — or null. */
|
||||
export function blockedReason(
|
||||
t: (k: string, o?: Record<string, unknown>) => string,
|
||||
p: { subdir: boolean; operation: string | null; dirty: boolean },
|
||||
needsClean = true
|
||||
): string | null {
|
||||
if (p.subdir) return t("blocked.subdir");
|
||||
if (p.operation) return t("blocked.operation", { op: p.operation });
|
||||
if (needsClean && p.dirty) return t("blocked.dirty");
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RebaseDialog({
|
||||
target,
|
||||
current,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
current: string | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [refs, setRefs] = useState<string[]>([]);
|
||||
const [onto, setOnto] = useState("");
|
||||
const [plan, setPlan] = useState<RebasePlan | null>(null);
|
||||
const [force, setForce] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
api.lanes
|
||||
.gitBranches(target)
|
||||
.then((b) => {
|
||||
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
|
||||
setRefs(list);
|
||||
})
|
||||
.catch((e) => setError(errText(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlan(null);
|
||||
setForce(false);
|
||||
if (!onto) return;
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planRebase(target, onto)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, onto]);
|
||||
|
||||
const blocked = plan ? blockedReason(t, plan) : null;
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase", {
|
||||
onto,
|
||||
head: plan.head,
|
||||
...(plan.pushed ? { force: true } : {}),
|
||||
});
|
||||
onChanged();
|
||||
if (r.conflicts?.length || r.stopped) {
|
||||
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success(t("rebase.done", { onto }));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("rebase.title", { branch: current ?? "HEAD" })}
|
||||
onClose={onClose}
|
||||
testId="rebase-dialog"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rebase-run"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={
|
||||
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
|
||||
}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t("rebase.run")}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : (
|
||||
<>
|
||||
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
|
||||
{t("rebase.onto")}
|
||||
</label>
|
||||
<select
|
||||
id="rebase-onto"
|
||||
data-testid="rebase-onto"
|
||||
value={onto}
|
||||
onChange={(e) => setOnto(e.target.value)}
|
||||
className="input mb-2 w-full py-1 text-[11px]"
|
||||
>
|
||||
<option value="">{t("rebase.pick")}</option>
|
||||
{refs.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
|
||||
{plan && (
|
||||
<div data-testid="rebase-plan" className="space-y-1">
|
||||
{blocked ? (
|
||||
<Warn>{blocked}</Warn>
|
||||
) : !plan.commits.length ? (
|
||||
<p>{t("rebase.nothing", { onto })}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{t("rebase.summary", {
|
||||
count: plan.commits.length,
|
||||
onto,
|
||||
behind: plan.behind,
|
||||
})}
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
|
||||
{plan.commits.map((c) => (
|
||||
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
|
||||
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
|
||||
{c.pushed && (
|
||||
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{plan.pushed > 0 && (
|
||||
<Warn testId="rebase-pushed">
|
||||
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="rebase-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
</Warn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @file ReflogView.tsx
|
||||
* @description The read-only reflog of HEAD or of one local branch, paged
|
||||
* (GET /git/reflog): each entry's selector, what moved it (commit, rebase,
|
||||
* reset, checkout…), git's message, when, and the commit it points at — a
|
||||
* click opens that commit in the Changes panel. "Reset here" hands the entry
|
||||
* to the reset dialog, which is how a lost commit is brought back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type ReflogEntry } from "../../../lib/gitRewrite";
|
||||
import { BTN, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
function when(time: number | null, lang: string): string {
|
||||
if (!time) return "";
|
||||
try {
|
||||
return new Date(time * 1000).toLocaleString(lang);
|
||||
} catch {
|
||||
return new Date(time * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export function ReflogView({
|
||||
target,
|
||||
onClose,
|
||||
onViewCommit,
|
||||
onReset,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
/** Open one commit (the panel's commit view). */
|
||||
onViewCommit?: (sha: string) => void;
|
||||
/** Open the reset dialog at this commit. */
|
||||
onReset?: (entry: ReflogEntry) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["gitTools"]);
|
||||
const [ref, setRef] = useState("HEAD");
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [entries, setEntries] = useState<ReflogEntry[] | null>(null);
|
||||
const [more, setMore] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
api.lanes
|
||||
.gitBranches(target)
|
||||
.then((b) => setBranches(b.branches ?? []))
|
||||
.catch(() => setBranches([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const page = async (skip: number) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.reflog(target, { ref, skip, limit: PAGE });
|
||||
setEntries((prev) => (skip && prev ? [...prev, ...r.entries] : r.entries));
|
||||
setMore(r.more);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
if (!skip) setEntries([]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setEntries(null);
|
||||
void page(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, ref]);
|
||||
|
||||
return (
|
||||
<GitModal title={t("reflog.title")} onClose={onClose} testId="reflog-view">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
<label className="text-[11px]" htmlFor="reflog-ref">
|
||||
{t("reflog.of")}
|
||||
</label>
|
||||
<select
|
||||
id="reflog-ref"
|
||||
data-testid="reflog-ref"
|
||||
value={ref}
|
||||
onChange={(e) => setRef(e.target.value)}
|
||||
className="input min-w-0 max-w-full flex-1 py-0.5 text-[11px]"
|
||||
>
|
||||
<option value="HEAD">HEAD</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="mb-1.5 text-[11px] text-fg-muted">{t("reflog.hint")}</p>
|
||||
<ErrorLine error={error} />
|
||||
{entries == null ? (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !entries.length ? (
|
||||
<p className="text-fg-muted">{t("reflog.empty")}</p>
|
||||
) : (
|
||||
<ul data-testid="reflog-list" className="divide-y divide-border">
|
||||
{entries.map((e) => (
|
||||
<li key={`${e.index}-${e.sha}`} className="flex flex-wrap items-start gap-1.5 py-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{e.selector}</span>
|
||||
<span className="shrink-0 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
|
||||
{e.action}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words text-[11px] text-fg-primary">
|
||||
{e.message || e.subject}
|
||||
<span className="block text-[10px] text-fg-muted">
|
||||
{when(e.time, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{onViewCommit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-[11px] text-accent hover:underline"
|
||||
title={e.subject}
|
||||
onClick={() => onViewCommit(e.sha)}
|
||||
>
|
||||
{e.short}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-[11px]" title={e.subject}>
|
||||
{e.short}
|
||||
</span>
|
||||
)}
|
||||
{onReset && (
|
||||
<button type="button" className={BTN} onClick={() => onReset(e)}>
|
||||
{t("reflog.resetHere")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{more && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reflog-more"
|
||||
disabled={busy}
|
||||
className={`${BTN} mt-2 w-full`}
|
||||
onClick={() => void page(entries?.length ?? 0)}
|
||||
>
|
||||
{t("reflog.more")}
|
||||
</button>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file ResetDialog.tsx
|
||||
* @description "Reset current branch to here": the plan first (GET
|
||||
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
|
||||
* how many of those leaving are already pushed), then one of four modes, each
|
||||
* explained: soft and mixed never touch the working tree, keep refuses to
|
||||
* overwrite a local change, and hard runs only on a clean tree with the branch
|
||||
* name typed back. Pushed commits need an explicit tick (a force-push will be
|
||||
* needed). A done reset stays open on its result with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import {
|
||||
errText,
|
||||
gitRewrite,
|
||||
type ResetMode,
|
||||
type ResetPlan,
|
||||
type RewriteResult,
|
||||
} from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
import { blockedReason } from "./RebaseDialog";
|
||||
|
||||
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
|
||||
|
||||
export function ResetDialog({
|
||||
target,
|
||||
commit,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
commit: { sha: string; short?: string; subject?: string };
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<ResetPlan | null>(null);
|
||||
const [mode, setMode] = useState<ResetMode>("mixed");
|
||||
const [force, setForce] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planReset(target, commit.sha)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, commit.sha]);
|
||||
|
||||
const branch = plan?.branch ?? "HEAD";
|
||||
const blocked = plan ? blockedReason(t, plan, false) : null;
|
||||
const hardBlocked = mode === "hard" && !!plan?.dirty;
|
||||
const ready =
|
||||
!!plan &&
|
||||
!blocked &&
|
||||
plan.related &&
|
||||
plan.head !== plan.target &&
|
||||
!(plan.pushed > 0 && !force) &&
|
||||
!hardBlocked &&
|
||||
(mode !== "hard" || typed === branch);
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "reset", {
|
||||
target: plan.target,
|
||||
mode,
|
||||
head: plan.head,
|
||||
confirm: true,
|
||||
...(plan.pushed ? { force: true } : {}),
|
||||
...(mode === "hard" ? { confirmBranch: typed } : {}),
|
||||
});
|
||||
onChanged();
|
||||
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
|
||||
onClose={onClose}
|
||||
testId="reset-dialog"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reset-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || !ready}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t("reset.run", { mode })}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : !plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{commit.subject && (
|
||||
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
|
||||
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
|
||||
{commit.subject}
|
||||
</p>
|
||||
)}
|
||||
{blocked && <Warn>{blocked}</Warn>}
|
||||
{!plan.related ? (
|
||||
<Warn>{t("reset.unrelated")}</Warn>
|
||||
) : plan.head === plan.target ? (
|
||||
<p>{t("reset.already")}</p>
|
||||
) : (
|
||||
<p data-testid="reset-summary" className="mb-1.5">
|
||||
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
|
||||
</p>
|
||||
)}
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
|
||||
{t("reset.mode")}
|
||||
</legend>
|
||||
{MODES.map((m) => (
|
||||
<label key={m} className="flex items-start gap-1.5 text-[11px]">
|
||||
<input
|
||||
type="radio"
|
||||
name="reset-mode"
|
||||
data-testid={`reset-mode-${m}`}
|
||||
value={m}
|
||||
checked={mode === m}
|
||||
onChange={() => setMode(m)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
|
||||
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{mode === "hard" &&
|
||||
(plan.dirty ? (
|
||||
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
|
||||
) : (
|
||||
<div className="mt-1.5">
|
||||
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
|
||||
{t("reset.typeBranch", { branch })}
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-branch"
|
||||
data-testid="reset-confirm-branch"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="input w-full font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{plan.pushed > 0 && (
|
||||
<Warn testId="reset-pushed">
|
||||
{t("reset.pushed", { count: plan.pushed })}
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="reset-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
</Warn>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file RewriteDone.tsx
|
||||
* @description What a finished rebase / interactive rebase / reset leaves on
|
||||
* screen: the result, a force-push reminder when the rewrite touched pushed
|
||||
* commits, and the recovery button — **Undo** = `undo-rewrite` (`reset --keep`
|
||||
* back to the recorded `before`), which the server runs only while HEAD is
|
||||
* still the `after` this dialog saw (409 ESTALE otherwise).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Undo2 } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type RewriteResult } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, ErrorLine, Warn } from "./GitModal";
|
||||
|
||||
export function RewriteDone({
|
||||
target,
|
||||
result,
|
||||
onChanged,
|
||||
onUndone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
result: RewriteResult;
|
||||
onChanged: () => void;
|
||||
onUndone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const canUndo = !!result.before && !!result.after && result.before !== result.after;
|
||||
|
||||
const undo = async () => {
|
||||
if (!isArmed("undo")) return arm("undo");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "undo-rewrite", {
|
||||
to: result.before,
|
||||
head: result.after,
|
||||
confirm: true,
|
||||
});
|
||||
toast.success(t("undone"));
|
||||
onChanged();
|
||||
onUndone();
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="git-rewrite-done" className="space-y-1.5">
|
||||
<p className="flex items-center gap-1.5 text-status-success">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
{t("doneTitle")}
|
||||
</p>
|
||||
{result.output && (
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-surface-2 p-1.5 font-mono text-[10px] text-fg-muted">
|
||||
{result.output}
|
||||
</pre>
|
||||
)}
|
||||
{result.needsForcePush && <Warn>{t("needsForcePushAfter")}</Warn>}
|
||||
{canUndo && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="git-rewrite-undo"
|
||||
disabled={busy}
|
||||
onClick={() => void undo()}
|
||||
className={`${BTN} flex items-center gap-1`}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
{isArmed("undo") ? t("undoConfirm") : t("undo")}
|
||||
</button>
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
{t("undoHint", { sha: result.before!.slice(0, 7) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's history tools: the 3-way merge editor
|
||||
* (three panes, per-block accept/both/edit, "N left", Save only when every
|
||||
* block is chosen and it sends the composed result with the opened sha), the
|
||||
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
|
||||
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
|
||||
* reset dialog (mode, hard needs a clean tree and the typed branch name).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MergeEditor, composeResult } from "../MergeEditor";
|
||||
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
|
||||
import { ResetDialog } from "../ResetDialog";
|
||||
import { RebaseSkipButton } from "../GitTools";
|
||||
import { gitRewrite } from "../../../../lib/gitRewrite";
|
||||
import { parseConflicts } from "../../../../lib/conflicts";
|
||||
|
||||
vi.mock("../../../../lib/gitRewrite", async (orig) => {
|
||||
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
|
||||
return {
|
||||
...real,
|
||||
gitRewrite: {
|
||||
conflict: vi.fn(),
|
||||
planInteractive: vi.fn(),
|
||||
planReset: vi.fn(),
|
||||
run: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const WORKING = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine",
|
||||
"=======",
|
||||
"theirs",
|
||||
">>>>>>> feat",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> feat",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const HEAD = "a".repeat(40);
|
||||
const commit = (n: number, pushed = false) => ({
|
||||
sha: String(n).repeat(40),
|
||||
short: String(n).repeat(7),
|
||||
subject: `c${n}`,
|
||||
message: `c${n}`,
|
||||
pushed,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(gitRewrite.conflict)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
operation: "merge",
|
||||
branch: "main",
|
||||
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
|
||||
base: "top\nbase\n",
|
||||
ours: "top\nmine\n",
|
||||
theirs: "top\ntheirs\n",
|
||||
working: WORKING,
|
||||
sha: "s1",
|
||||
usable: true,
|
||||
binary: false,
|
||||
tooBig: false,
|
||||
});
|
||||
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
|
||||
});
|
||||
|
||||
describe("MergeEditor", () => {
|
||||
it("shows three panes and saves the composed result once every block is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
|
||||
const incoming = await screen.findByTestId("merge-incoming");
|
||||
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
|
||||
expect(incoming).toHaveTextContent("theirs");
|
||||
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
|
||||
const save = screen.getByTestId("merge-save");
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
|
||||
await user.click(within(first).getByTestId("merge-accept-incoming"));
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
|
||||
await user.click(within(second).getByTestId("merge-both-ic"));
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
|
||||
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
|
||||
path: "a.txt",
|
||||
content: "top\ntheirs\nmiddle\nb\na\n",
|
||||
baseSha: "s1",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits one block by hand, and the base pane is one click away", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
||||
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
|
||||
await user.click(within(first).getByTestId("merge-edit-block"));
|
||||
const box = within(first).getByTestId("merge-block-text");
|
||||
await user.clear(box);
|
||||
await user.type(box, "hand");
|
||||
await user.click(within(first).getByRole("button", { name: "Done" }));
|
||||
expect(first).toHaveTextContent("hand");
|
||||
expect(screen.queryByTestId("merge-base")).toBeNull();
|
||||
await user.click(screen.getByLabelText("Show base"));
|
||||
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
|
||||
});
|
||||
|
||||
it("labels the sides for a rebase and explains a refused save", async () => {
|
||||
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
|
||||
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
|
||||
operation: "rebase",
|
||||
});
|
||||
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
|
||||
Object.assign(new Error("x"), { code: "EMARKERS" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
||||
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
|
||||
"the branch you are rebasing onto"
|
||||
);
|
||||
await user.click(screen.getByTestId("merge-whole-toggle"));
|
||||
await user.click(screen.getByTestId("merge-save"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
|
||||
});
|
||||
|
||||
it("composeResult keeps markers on an unresolved block", () => {
|
||||
const parts = parseConflicts(WORKING.split("\n"))!;
|
||||
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
|
||||
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RebaseSkipButton", () => {
|
||||
it("skips the stopped commit and re-reads", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
|
||||
await user.click(screen.getByTestId("rebase-skip"));
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveRebase", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(gitRewrite.planInteractive)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
kind: "interactive",
|
||||
head: HEAD,
|
||||
branch: "feat",
|
||||
subdir: false,
|
||||
operation: null,
|
||||
dirty: false,
|
||||
base: "b".repeat(40),
|
||||
merges: false,
|
||||
commits: [commit(1, true), commit(2), commit(3)],
|
||||
pushed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
||||
action: "rebase-interactive",
|
||||
ok: true,
|
||||
output: "",
|
||||
before: HEAD,
|
||||
after: "c".repeat(40),
|
||||
conflicts: [],
|
||||
stopped: false,
|
||||
needsForcePush: true,
|
||||
});
|
||||
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
|
||||
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
|
||||
);
|
||||
const run = screen.getByTestId("interactive-run");
|
||||
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
|
||||
|
||||
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
|
||||
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
|
||||
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
|
||||
const msg = within(rows[2]!).getByTestId("interactive-message");
|
||||
await user.clear(msg);
|
||||
await user.type(msg, "new words");
|
||||
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
|
||||
expect(run).toBeDisabled();
|
||||
await user.click(screen.getByTestId("interactive-force"));
|
||||
expect(run).toBeEnabled();
|
||||
|
||||
await user.click(run);
|
||||
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
|
||||
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
|
||||
head: HEAD,
|
||||
confirm: true,
|
||||
force: true,
|
||||
todo: [
|
||||
{ sha: commit(1).sha, action: "pick" },
|
||||
{ sha: commit(3).sha, action: "reword", message: "new words" },
|
||||
{ sha: commit(2).sha, action: "squash" },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
|
||||
"Force push (with lease)"
|
||||
);
|
||||
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
|
||||
const c = commit(1);
|
||||
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
|
||||
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
|
||||
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
|
||||
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResetDialog", () => {
|
||||
const plan = (over = {}) => ({
|
||||
available: true,
|
||||
kind: "reset" as const,
|
||||
head: HEAD,
|
||||
branch: "feat",
|
||||
subdir: false,
|
||||
operation: null,
|
||||
dirty: false,
|
||||
target: "d".repeat(40),
|
||||
related: true,
|
||||
dropped: 2,
|
||||
added: 0,
|
||||
pushed: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("hard needs the branch name typed back", async () => {
|
||||
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
|
||||
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
||||
action: "reset",
|
||||
ok: true,
|
||||
output: "",
|
||||
before: HEAD,
|
||||
after: "d".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResetDialog
|
||||
target={3}
|
||||
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
|
||||
onClose={vi.fn()}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
|
||||
await user.click(screen.getByTestId("reset-mode-hard"));
|
||||
const run = screen.getByTestId("reset-run");
|
||||
expect(run).toBeDisabled();
|
||||
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
|
||||
expect(run).toBeEnabled();
|
||||
await user.click(run);
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
|
||||
target: "d".repeat(40),
|
||||
mode: "hard",
|
||||
head: HEAD,
|
||||
confirm: true,
|
||||
confirmBranch: "feat",
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
|
||||
vi.mocked(gitRewrite.planReset)
|
||||
.mockReset()
|
||||
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResetDialog
|
||||
target={3}
|
||||
commit={{ sha: "d".repeat(40) }}
|
||||
onClose={vi.fn()}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await screen.findByTestId("reset-pushed");
|
||||
const run = screen.getByTestId("reset-run");
|
||||
expect(run).toBeDisabled();
|
||||
await user.click(screen.getByTestId("reset-force"));
|
||||
expect(run).toBeEnabled(); // mixed works on a dirty tree
|
||||
await user.click(screen.getByTestId("reset-mode-hard"));
|
||||
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
|
||||
expect(run).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user