Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/commit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -74,6 +76,8 @@ export default async function RootLayout({
<WebConnectionGuard />
<WindowResizeGrips />
{children}
<WebCommitDialog />
<WebSettingsDialog />
</AppearanceProvider>
</ThemeProvider>
</AppI18nProvider>
Expand Down
64 changes: 64 additions & 0 deletions src/components/layout/web-commit-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WebCommitDialog />)

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(<WebCommitDialog />)
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(<WebCommitDialog />)
act(() => emitOpenCommitDialog("/commit?folderId=42"))

const frame = screen.getByTitle<HTMLIFrameElement>("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(<WebCommitDialog />)

act(() => {
window.dispatchEvent(
new CustomEvent(OPEN_COMMIT_DIALOG_EVENT, {
detail: { path: "/login" },
})
)
})

expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})
})
124 changes: 124 additions & 0 deletions src/components/layout/web-commit-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<OpenCommitDialogDetail>).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<HTMLIFrameElement>) => {
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 (
<Dialog open={frame !== null} onOpenChange={handleOpenChange}>
<DialogContent
className="block h-[min(820px,calc(100dvh-2rem))] w-[min(1220px,calc(100vw-2rem))] max-w-none overflow-hidden rounded-2xl p-0 sm:max-w-none"
closeButtonClassName="top-2 right-2 z-20 size-6 bg-background/80 backdrop-blur-sm [&_svg]:size-3"
>
<DialogTitle className="sr-only">{t("title")}</DialogTitle>

{!loaded && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-background">
<LoaderCircle
className="h-6 w-6 animate-spin text-muted-foreground"
aria-hidden="true"
/>
</div>
)}

{frame && (
<iframe
key={frame.sequence}
src={frame.path}
title={t("title")}
className="h-full w-full border-0 bg-background"
onLoad={handleFrameLoad}
/>
)}
</DialogContent>
</Dialog>
)
}
63 changes: 63 additions & 0 deletions src/components/settings/web-settings-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

vi.mock("next-intl", () => ({ useTranslations: () => () => "Settings" }))

import { WebSettingsDialog } from "./web-settings-dialog"
import {
emitOpenSettingsDialog,
OPEN_SETTINGS_DIALOG_EVENT,
} from "@/lib/settings-dialog-events"

afterEach(() => cleanup())

describe("WebSettingsDialog", () => {
it("opens the requested settings route in an in-page dialog", () => {
render(<WebSettingsDialog />)

act(() => emitOpenSettingsDialog("/settings/agents?agent=codex"))

const frame = screen.getByTitle("Settings")
expect(frame).toHaveAttribute("src", "/settings/agents?agent=codex")
expect(screen.getByRole("dialog")).toBeInTheDocument()
})

it("closes and removes the settings frame", () => {
render(<WebSettingsDialog />)
act(() => emitOpenSettingsDialog("/settings/appearance"))

fireEvent.click(screen.getByRole("button", { name: "Close" }))

expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(screen.queryByTitle("Settings")).not.toBeInTheDocument()
})

it("closes on Escape while focus is inside the settings frame", () => {
render(<WebSettingsDialog />)
act(() => emitOpenSettingsDialog("/settings/appearance"))

const frame = screen.getByTitle<HTMLIFrameElement>("Settings")
fireEvent.load(frame)
act(() => {
frame.contentWindow?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape" })
)
})

expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})

it("ignores navigation outside the settings routes", () => {
render(<WebSettingsDialog />)

act(() => {
window.dispatchEvent(
new CustomEvent(OPEN_SETTINGS_DIALOG_EVENT, {
detail: { path: "/login" },
})
)
})

expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})
})
102 changes: 102 additions & 0 deletions src/components/settings/web-settings-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"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 {
OPEN_SETTINGS_DIALOG_EVENT,
type OpenSettingsDialogDetail,
} from "@/lib/settings-dialog-events"

export function WebSettingsDialog() {
const t = useTranslations("SettingsShell")
const frameSequence = useRef(0)
const [frame, setFrame] = useState<{
path: string
sequence: number
} | null>(null)
const [loaded, setLoaded] = useState(false)

useEffect(() => {
const handleOpenSettings = (event: Event) => {
const nextPath = (event as CustomEvent<OpenSettingsDialogDetail>).detail
?.path
if (!nextPath?.startsWith("/settings/")) return

setLoaded(false)
frameSequence.current += 1
setFrame({ path: nextPath, sequence: frameSequence.current })
}

window.addEventListener(OPEN_SETTINGS_DIALOG_EVENT, handleOpenSettings)
return () => {
window.removeEventListener(OPEN_SETTINGS_DIALOG_EVENT, handleOpenSettings)
}
}, [])

const handleOpenChange = useCallback((open: boolean) => {
if (open) return
setFrame(null)
setLoaded(false)
}, [])

const handleFrameKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === "Escape") handleOpenChange(false)
},
[handleOpenChange]
)

const handleFrameLoad = useCallback(
(event: SyntheticEvent<HTMLIFrameElement>) => {
setLoaded(true)
try {
event.currentTarget.contentWindow?.addEventListener(
"keydown",
handleFrameKeyDown
)
} catch {
// The settings route is expected to be same-origin. Keep the close
// button usable if a deployment rewrites it to another origin.
}
},
[handleFrameKeyDown]
)

return (
<Dialog open={frame !== null} onOpenChange={handleOpenChange}>
<DialogContent
className="block h-[min(700px,calc(100dvh-2rem))] w-[min(1080px,calc(100vw-2rem))] max-w-none overflow-hidden rounded-2xl p-0 sm:max-w-none"
closeButtonClassName="top-2 right-2 z-20 size-6 bg-background/80 backdrop-blur-sm [&_svg]:size-3"
>
<DialogTitle className="sr-only">{t("title")}</DialogTitle>

{!loaded && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-background">
<LoaderCircle
className="h-6 w-6 animate-spin text-muted-foreground"
aria-hidden="true"
/>
</div>
)}

{frame && (
<iframe
key={frame.sequence}
src={frame.path}
title={t("title")}
className="h-full w-full border-0 bg-background"
onLoad={handleFrameLoad}
/>
)}
</DialogContent>
</Dialog>
)
}
Loading