diff --git a/src/app/commit/page.tsx b/src/app/commit/page.tsx index 55fda42b6..f673791d3 100644 --- a/src/app/commit/page.tsx +++ b/src/app/commit/page.tsx @@ -13,6 +13,8 @@ import { AppTitleBar } from "@/components/layout/app-title-bar" import { AppToaster } from "@/components/ui/app-toaster" import { getFolder } from "@/lib/api" import { toErrorMessage } from "@/lib/app-error" +import { emitCloseCommitDialog } from "@/lib/commit-dialog-events" +import { isDesktop } from "@/lib/transport" import type { FolderDetail } from "@/lib/types" import { GitCredentialProvider } from "@/contexts/git-credential-context" import { RemoteConnectionGate } from "@/contexts/remote-connection-context" @@ -42,6 +44,11 @@ function CommitPageInner() { const error = state.loadedId === normalizedFolderId ? state.error : null const closeWindow = useCallback(async () => { + if (!isDesktop()) { + emitCloseCommitDialog() + return + } + try { const win = await getCurrentWindow() await win.close() diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 852464b20..8d37429e9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,8 @@ import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init" import { ClipboardFallbackInit } from "@/components/clipboard-fallback-init" import { WebConnectionGuard } from "@/components/connection/web-connection-guard" import { WindowResizeGrips } from "@/components/layout/window-resize-grips" +import { WebCommitDialog } from "@/components/layout/web-commit-dialog" +import { WebSettingsDialog } from "@/components/settings/web-settings-dialog" export const viewport: Viewport = { width: "device-width", @@ -74,6 +76,8 @@ export default async function RootLayout({ {children} + + diff --git a/src/components/layout/web-commit-dialog.test.tsx b/src/components/layout/web-commit-dialog.test.tsx new file mode 100644 index 000000000..08e3e3e5c --- /dev/null +++ b/src/components/layout/web-commit-dialog.test.tsx @@ -0,0 +1,64 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +vi.mock("next-intl", () => ({ useTranslations: () => () => "Commit" })) + +import { WebCommitDialog } from "./web-commit-dialog" +import { + emitCloseCommitDialog, + emitOpenCommitDialog, + OPEN_COMMIT_DIALOG_EVENT, +} from "@/lib/commit-dialog-events" + +afterEach(() => cleanup()) + +describe("WebCommitDialog", () => { + it("opens the requested commit route in an in-page dialog", () => { + render() + + act(() => emitOpenCommitDialog("/commit?folderId=42")) + + const frame = screen.getByTitle("Commit") + expect(frame).toHaveAttribute("src", "/commit?folderId=42") + expect(screen.getByRole("dialog")).toBeInTheDocument() + }) + + it("closes when the embedded commit page emits its close event", () => { + render() + act(() => emitOpenCommitDialog("/commit?folderId=42")) + + act(() => emitCloseCommitDialog()) + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(screen.queryByTitle("Commit")).not.toBeInTheDocument() + }) + + it("closes on Escape while focus is inside the commit frame", () => { + render() + act(() => emitOpenCommitDialog("/commit?folderId=42")) + + const frame = screen.getByTitle("Commit") + fireEvent.load(frame) + act(() => { + frame.contentWindow?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape" }) + ) + }) + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("ignores navigation outside the commit route", () => { + render() + + act(() => { + window.dispatchEvent( + new CustomEvent(OPEN_COMMIT_DIALOG_EVENT, { + detail: { path: "/login" }, + }) + ) + }) + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/layout/web-commit-dialog.tsx b/src/components/layout/web-commit-dialog.tsx new file mode 100644 index 000000000..eeef11a15 --- /dev/null +++ b/src/components/layout/web-commit-dialog.tsx @@ -0,0 +1,124 @@ +"use client" + +import { + useCallback, + useEffect, + useRef, + useState, + type SyntheticEvent, +} from "react" +import { LoaderCircle } from "lucide-react" +import { useTranslations } from "next-intl" +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" +import { + CLOSE_COMMIT_DIALOG_EVENT, + OPEN_COMMIT_DIALOG_EVENT, + type OpenCommitDialogDetail, +} from "@/lib/commit-dialog-events" + +function isCommitPath(path: string): boolean { + try { + const url = new URL(path, window.location.origin) + const pathname = url.pathname + .replace(/\/index\.html$/, "") + .replace(/\.html$/, "") + .replace(/\/+$/, "") + return url.origin === window.location.origin && pathname === "/commit" + } catch { + return false + } +} + +export function WebCommitDialog() { + const t = useTranslations("CommitPage") + const frameSequence = useRef(0) + const [frame, setFrame] = useState<{ + path: string + sequence: number + } | null>(null) + const [loaded, setLoaded] = useState(false) + + const closeDialog = useCallback(() => { + setFrame(null) + setLoaded(false) + }, []) + + useEffect(() => { + const handleOpenCommit = (event: Event) => { + const nextPath = (event as CustomEvent).detail + ?.path + if (!nextPath || !isCommitPath(nextPath)) return + + setLoaded(false) + frameSequence.current += 1 + setFrame({ path: nextPath, sequence: frameSequence.current }) + } + + window.addEventListener(OPEN_COMMIT_DIALOG_EVENT, handleOpenCommit) + window.addEventListener(CLOSE_COMMIT_DIALOG_EVENT, closeDialog) + return () => { + window.removeEventListener(OPEN_COMMIT_DIALOG_EVENT, handleOpenCommit) + window.removeEventListener(CLOSE_COMMIT_DIALOG_EVENT, closeDialog) + } + }, [closeDialog]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) closeDialog() + }, + [closeDialog] + ) + + const handleFrameKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") closeDialog() + }, + [closeDialog] + ) + + const handleFrameLoad = useCallback( + (event: SyntheticEvent) => { + setLoaded(true) + try { + event.currentTarget.contentWindow?.addEventListener( + "keydown", + handleFrameKeyDown + ) + } catch { + // The commit route is expected to be same-origin. The close button + // remains available if a deployment rewrites it to another origin. + } + }, + [handleFrameKeyDown] + ) + + return ( + + + {t("title")} + + {!loaded && ( +
+
+ )} + + {frame && ( +