feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ? shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's own), the mobile drawer with swipe support (layout/Drawer), and the visual-viewport hook (hooks/useViewport). Each is a standalone, tested port of its vanilla-DOM source; wiring into the shell is a later issue.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { keyCaps } from "../../commands.js";
|
||||
import { groupedCommands } from "../../help.js";
|
||||
import { HelpOverlay } from "./HelpOverlay.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}): void {
|
||||
fireEvent.keyDown(window, { key, ...init });
|
||||
}
|
||||
|
||||
describe("HelpOverlay", () => {
|
||||
it("opens on ? and closes on a second ?", () => {
|
||||
render(<HelpOverlay />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
press("?", { shiftKey: true });
|
||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||
press("?", { shiftKey: true });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores ? while typing and when a non-shift modifier is held", () => {
|
||||
render(<HelpOverlay />);
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
fireEvent.keyDown(input, { key: "?", shiftKey: true });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
press("?", { ctrlKey: true });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("closes on Escape, on the close button, and on a click outside the panel", () => {
|
||||
render(<HelpOverlay />);
|
||||
|
||||
press("?", { shiftKey: true });
|
||||
press("Escape");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
press("?", { shiftKey: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
press("?", { shiftKey: true });
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("stays open for a click inside the panel", () => {
|
||||
render(<HelpOverlay />);
|
||||
press("?", { shiftKey: true });
|
||||
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
|
||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders exactly the commands the registry has bound, grouped", () => {
|
||||
render(<HelpOverlay />);
|
||||
press("?", { shiftKey: true });
|
||||
|
||||
const groups = groupedCommands();
|
||||
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
||||
groups.map((g) => g.title),
|
||||
);
|
||||
expect(
|
||||
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
|
||||
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
|
||||
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
|
||||
groups.flatMap((g) => g.items.map((c) => c.label)),
|
||||
);
|
||||
expect(
|
||||
[...document.querySelectorAll(".help-row")].map((r) =>
|
||||
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
|
||||
),
|
||||
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// React port of ../../help.ts: the `?` overlay renders the command registry
|
||||
// (commands.ts) directly — the same list that dispatches the keys — so a
|
||||
// binding can never be documented as one thing and do another.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
|
||||
import { groupedCommands } from "../../help.js";
|
||||
|
||||
export function HelpOverlay() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
|
||||
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (open && overlayKeys.handle(e)) return;
|
||||
helpKeys.handle(e);
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
return () => window.removeEventListener("keydown", onKeydown);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Keyboard shortcuts"
|
||||
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
|
||||
<header className="help-head flex items-center justify-between">
|
||||
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
|
||||
onClick={close}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
{groupedCommands().map((group) => (
|
||||
<section key={group.title} className="help-group mt-sp-3">
|
||||
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
|
||||
<dl className="help-list mt-sp-1">
|
||||
{group.items.map((cmd) => (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
|
||||
data-command-id={cmd.id}
|
||||
>
|
||||
<dt className="help-keys m-0 flex gap-sp-1">
|
||||
{keyCaps(cmd.keys[0]).map((cap, i) => (
|
||||
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
|
||||
{cap}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { KillDialog } from "./KillDialog.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("KillDialog", () => {
|
||||
it("focuses the kill button, so Enter confirms", () => {
|
||||
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
|
||||
});
|
||||
|
||||
it("focuses the force button too", () => {
|
||||
render(
|
||||
<KillDialog
|
||||
name="alpha"
|
||||
blockers={null}
|
||||
force
|
||||
reason="worktree is dirty."
|
||||
onConfirm={() => Promise.resolve()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
|
||||
});
|
||||
|
||||
it("names the blockers in the confirm body", () => {
|
||||
render(
|
||||
<KillDialog
|
||||
name="alpha"
|
||||
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
|
||||
onConfirm={() => Promise.resolve()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cancels on Cancel, on Escape and on a backdrop click", () => {
|
||||
const onCancel = vi.fn();
|
||||
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
first.unmount();
|
||||
|
||||
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalledTimes(2);
|
||||
second.unmount();
|
||||
|
||||
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
|
||||
let reject: (e: Error) => void = () => {};
|
||||
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
|
||||
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
|
||||
|
||||
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
|
||||
fireEvent.click(kill);
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
expect(kill.disabled).toBe(true);
|
||||
|
||||
reject(new Error("daemon unreachable"));
|
||||
await waitFor(() => expect(kill.disabled).toBe(false));
|
||||
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
|
||||
// daemon refuses an unforced kill over a worktree holding unsaved work; the
|
||||
// same component in `force` mode is the override that follows that refusal.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { formatInTheWay, type InTheWay } from "../../types.js";
|
||||
|
||||
export interface KillDialogProps {
|
||||
/** Session name, as the sidebar shows it. */
|
||||
name: string;
|
||||
/** What the card last saw in the way, when anything was. */
|
||||
blockers: InTheWay | null;
|
||||
/** Set once the daemon has refused an unforced kill: this pass forces it,
|
||||
* and `reason` carries the daemon's refusal message. */
|
||||
force?: boolean;
|
||||
/** Message from the daemon's refusal, shown when there are no blockers to
|
||||
* spell out. */
|
||||
reason?: string;
|
||||
/** Run the kill (or the forced kill). A rejection is rendered inline, and
|
||||
* the dialog stays open so the press can be retried. */
|
||||
onConfirm(): Promise<void>;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
export function KillDialog(props: KillDialogProps) {
|
||||
const { name, blockers, force, reason, onConfirm, onCancel } = props;
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The acting button takes the focus in both modes: the operator already
|
||||
// asked for the kill, so Enter confirms it.
|
||||
useEffect(() => confirmRef.current?.focus(), []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
void onConfirm().catch((e: unknown) => {
|
||||
setPending(false);
|
||||
setError(`Failed to kill: ${(e as Error).message}`);
|
||||
});
|
||||
};
|
||||
|
||||
const body = blockers
|
||||
? force
|
||||
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
|
||||
: force
|
||||
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
|
||||
: "The session and everything running in it go away. Its worktree stays on disk.";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Kill ${name}`}
|
||||
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
|
||||
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill "{name}"?</h2>
|
||||
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
|
||||
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
|
||||
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
|
||||
<button
|
||||
type="button"
|
||||
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
disabled={pending}
|
||||
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
|
||||
force ? "kill-dialog-force" : "kill-dialog-kill"
|
||||
}`}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{force ? "Force kill" : "Kill"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
|
||||
export { HelpOverlay } from "./HelpOverlay.js";
|
||||
Reference in New Issue
Block a user