Files
AgentHub/client/src/components/lanes/git/GitModal.tsx
T

154 lines
4.8 KiB
TypeScript

/**
* @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; `tall` gives a viewer (blame, browse at a
* revision, compare refs) the full height and a column body its own scrolling
* child can fill; on a phone every size is the full width. `ModalButton` is
* the History tools' footer button over the same BTN classes.
* @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,
tall = false,
footer,
children,
}: {
title: ReactNode;
onClose: () => void;
testId?: string;
wide?: boolean;
/** A viewer: the full height, and a flex-column body. */
tall?: 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 || tall ? "max-w-6xl" : "max-w-lg"
} ${tall ? "h-[92vh]" : ""}`}
>
<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 px-3 py-2 text-xs text-fg-secondary ${
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
}`}
>
{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>
);
}
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
export function ModalButton({
onClick,
children,
primary = false,
danger = false,
disabled = false,
testId,
title,
}: {
onClick: () => void;
children: ReactNode;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
testId?: string;
title?: string;
}) {
return (
<button
type="button"
data-testid={testId}
title={title}
disabled={disabled}
onClick={onClick}
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
>
{children}
</button>
);
}