"use client"; import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts"; import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuGroup, MenuGroupLabel, MenuSeparator, MenuSub, MenuSubPopup, MenuSubTrigger, MenuTrigger, } from "~/components/ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { previewBridge } from "./previewBridge"; const COLOR_SCHEME_OPTIONS: ReadonlyArray<{ value: DesktopPreviewColorScheme; label: string; }> = [ { value: "system", label: "System" }, { value: "light", label: "Light" }, { value: "dark", label: "Dark" }, ]; interface Props { /** Active preview tab id. Tab-targeting actions are disabled without it. */ tabId: string | null; /** * True only after the desktop bridge has registered a `webContentsId` for * the active tab. Tab-targeting actions throw on the desktop side until * then; we disable those items so the menu doesn't fire silent no-ops. */ hasWebContents: boolean; /** Current zoom factor as a number (1.0 = 100%). */ zoomFactor: number; /** Emulated `prefers-color-scheme` for the guest page. */ colorScheme: DesktopPreviewColorScheme; /** Fixed viewport modes expose the device toolbar and resize rails. */ deviceToolbarVisible: boolean; /** Switches between fill-panel mode and a fixed responsive viewport. */ onToggleDeviceToolbar: () => void; /** Whether the separate native always-on-top preview window is open. */ nativePictureInPicture: boolean; /** Toggles the optional native always-on-top preview window. */ onNativePictureInPicture: () => void; /** Environment the tab belongs to; scopes storage clearing to its partitions. */ environmentId: EnvironmentId; /** Profile the tab was opened under, if the server recorded one. */ /** * Required: the IPC layer reads an absent profile as "every profile", so a * tab whose own profile is unknown must resolve the default before it gets * here rather than passing the gap along. */ profileId: string; /** Profile display name, shown so the menu says which data is being cleared. */ profileName: string | undefined; } /** * Three-dot menu in the chrome row. Wires Hard reload, DevTools, zoom * controls, and storage-clearing actions. Only mounted by `PreviewView` * when the desktop bridge is present, so we can call it unconditionally. */ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor, colorScheme, deviceToolbarVisible, onToggleDeviceToolbar, nativePictureInPicture, onNativePictureInPicture, environmentId, profileId, profileName, }: Props) { if (!previewBridge) return null; const bridge = previewBridge; const tabDisabled = !tabId || !hasWebContents; const callTab = (op: (tabId: string) => Promise) => () => { if (!tabId) return; void op(tabId).catch(() => undefined); }; const zoomLabel = `${Math.round(zoomFactor * 100)}%`; return ( } /> } > More Hard reload Open DevTools {nativePictureInPicture ? "Close separate preview window" : "Open separate preview window"} {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} Appearance { if (!tabId) return; void bridge .setColorScheme(tabId, value as DesktopPreviewColorScheme) .catch(() => undefined); }} > {COLOR_SCHEME_OPTIONS.map((option) => ( {option.label} ))} {/* Zoom row: label + inline control cluster. `closeOnClick=false` keeps the menu open while the user clicks the +/− buttons. */} event.preventDefault()} className="justify-between" disabled={tabDisabled} > Zoom {zoomLabel} {/* Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel` reads its context and throws without one. The heading also answers which profile the tab is in, which is otherwise invisible: it is fixed at open and nothing else in the chrome shows it. */} {/* The heading carries the profile so the actions below can keep fixed-length labels: repeating a name of up to 48 characters in each one drove the popup far past its width. */} {profileName ? ( // Truncation sits on the label itself: it renders a block box, so // `text-overflow` on an inline child inside it never applies and a // long name would push the popup past its width instead. Profile: {profileName} ) : null} void bridge.clearCookies(environmentId, profileId).catch(() => undefined) } > Clear cookies void bridge.clearCache(environmentId, profileId).catch(() => undefined)} > Clear cache ); }