1358 lines · 50.8 KB · TypeScript
RawDownload
import { DeviceHostsSettings } from "./DeviceHostsSettings";/** * Integrations settings - preferences for surfaces T3 Code embeds rather than * owns. Browser is the first section: the defaults a preview tab opens at, * applied to both hand-opened tabs and agent `preview_open` calls that don't * state their own size. * * @module IntegrationsSettings */import {  BrowserImportFailureReason,  BROWSER_PROFILE_MAX_COUNT,  type BrowserLinkTarget,  type BrowserProfile,  type EnvironmentId,  type SshDeviceHostConfig,  BROWSER_PROFILE_NAME_MAX_LENGTH,  BROWSER_RECORDING_FRAME_RATES,  DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,  DEFAULT_BROWSER_PROFILE_ID,  DEFAULT_BROWSER_LINK_TARGET,  DEFAULT_BROWSER_RECORDING_FRAME_RATE,  DEFAULT_BROWSER_VIEWPORT,  DEFAULT_PREVIEW_APPEARANCE,  DEFAULT_PREVIEW_ZOOM_FACTOR,  FILL_PREVIEW_VIEWPORT,  PREVIEW_VIEWPORT_MAX_AREA,  PREVIEW_VIEWPORT_MAX_DIMENSION,  PREVIEW_VIEWPORT_MIN_DIMENSION,  PREVIEW_ZOOM_LEVELS,  findBrowserProfile,  isBuiltInBrowserProfileId,  resolveBrowserProfiles,  type BrowserImportSource,  type PreviewAppearancePreference,  type PreviewViewportSetting,} from "@t3tools/contracts";import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";import { Link } from "@tanstack/react-router";import { MoreVertical, Plus as PlusIcon } from "lucide-react";import { useCallback, useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";import { AnimatedHeight } from "~/components/AnimatedHeight";import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";import { previewBridge } from "~/components/preview/previewBridge";import { cn, randomUUID } from "~/lib/utils";import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";import { deviceEnvironment, useDeviceState } from "~/state/device";import { useAtomCommand } from "~/state/use-atom-command";import {  AgentDeviceSetupStatus,  DeviceHubSetupStatus,  PlatformStatus,  platformSetupStatus,  deviceHubDescription,  agentDeviceDescription,} from "~/components/device/DeviceSetup";import { isElectron } from "../../env"; import { Badge } from "../ui/badge";import {  Menu,  MenuGroup,  MenuGroupLabel,  MenuItem,  MenuPopup,  MenuSeparator,  MenuTrigger,} from "../ui/menu";import { readLocalApi } from "~/localApi"; import { toastManager } from "../ui/toast";import {  AlertDialog,  AlertDialogClose,  AlertDialogDescription,  AlertDialogFooter,  AlertDialogHeader,  AlertDialogPopup,  AlertDialogTitle,} from "../ui/alert-dialog";import { Button } from "../ui/button";import { DraftInput } from "../ui/draft-input";import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";import {  Select,  SelectGroup,  SelectGroupLabel,  SelectItem,  SelectPopup,  SelectTrigger,  SelectValue,} from "../ui/select";import { Switch } from "../ui/switch";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import {  getClientSettings,  persistClientSettingsUpdate,  useClientSettings,  useClientSettingsHydrated,  useUpdatePrimarySettings,} from "~/hooks/useSettings"; import {  SettingsUnavailableGroup,  SettingResetButton,  SettingsPageContainer,  SettingsRow,  SettingsSection,} from "./settingsLayout";import { searchableSetting } from "./settingsSearch";import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard";import type { ImportOutcome } from "./browserImportWizard.logic"; const FILL_VALUE = "fill";const RESPONSIVE_VALUE = "responsive"; type BrowserProfileDataBridge = Pick<  NonNullable<typeof previewBridge>,  "clearCookies" | "clearCache">; export async function clearBrowserProfileData(  bridge: BrowserProfileDataBridge | null,  environmentIds: ReadonlyArray<EnvironmentId>,  profileId: string,): Promise<void> {  if (bridge === null || environmentIds.length === 0) {    throw new Error("Browser profile data is not available to clear.");  }  await Promise.all(    environmentIds.flatMap((environmentId) => [      bridge.clearCookies(environmentId, profileId),      bridge.clearCache(environmentId, profileId),    ]),  );} export function browserProfileRemovalAvailable(  bridgeAvailable: boolean,  environmentsReady: boolean,  environmentCount: number,): boolean {  return bridgeAvailable && environmentsReady && environmentCount > 0;} /** * The size a "Responsive" default falls back to when the user switches away * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry * over, so the picker needs something concrete to seed the inputs with. */const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const; const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false }; const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = {  system: "System",  light: "Light",  dark: "Dark",}; const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; /** * IPC flattens the failure to its message, so the reason token travels inside * it. Anything unrecognised reads as a plain read failure rather than leaking * the raw message into a toast. *//** Thrown from the post-import settings updater when the cap was hit meanwhile. */class ProfileLimitReachedError extends Error {  constructor() {    super("Browser profile limit reached.");    this.name = "ProfileLimitReachedError";  }} export const importFailureReason = (cause: unknown): BrowserImportFailureReason => {  const message = String((cause as { message?: unknown } | undefined)?.message ?? "");  return (    BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ??    "readFailed"  );}; const viewportSelectValue = (viewport: PreviewViewportSetting): string => {  if (viewport._tag === "fill") return FILL_VALUE;  if (    viewport._tag === "preset" &&    PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === viewport.presetId)  ) {    return viewport.presetId;  }  return RESPONSIVE_VALUE;}; /** * The trigger renders this rather than a bare `SelectValue`, which would fall * back to printing the raw stored value ("fill") because the options are built * inline instead of from an `items` map. */const viewportSelectLabel = (viewport: PreviewViewportSetting): string => {  const value = viewportSelectValue(viewport);  if (value === FILL_VALUE) return "Fill panel";  if (value === RESPONSIVE_VALUE) return "Responsive";  return PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === value)?.label ?? "Responsive";}; const isValidDimension = (value: number) =>  Number.isInteger(value) &&  value >= PREVIEW_VIEWPORT_MIN_DIMENSION &&  value <= PREVIEW_VIEWPORT_MAX_DIMENSION; /** * A sized viewport with width and height swapped. Presets keep their identity * through a rotation — `resolvePreviewViewport` already stores rotated presets * as the preset id plus swapped dimensions — so a rotated iPad is still an * iPad, not an anonymous custom size. */const rotateViewport = (  viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,): PreviewViewportSetting => ({  ...viewport,  width: viewport.height,  height: viewport.width,}); function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) {  const viewport = useClientSettings((settings) => settings.browserDefaultViewport);  const updateSettings = useUpdatePrimarySettings();   const sized = viewport._tag === "fill" ? null : viewport;  const presentedSize = {    width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,    height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,  };   const selectViewport = (value: string | null) => {    if (value === FILL_VALUE) {      updateSettings({ browserDefaultViewport: FILL_PREVIEW_VIEWPORT });      return;    }    if (value === RESPONSIVE_VALUE) {      updateSettings({        browserDefaultViewport: {          _tag: "freeform",          width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,          height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,        },      });      return;    }    const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);    if (!preset) return;    updateSettings({      browserDefaultViewport: {        _tag: "preset",        width: preset.width,        height: preset.height,        presetId: preset.id,      },    });  };   // Committed on blur rather than per keystroke: typing "2560" passes through  // "256", which is a legal dimension, so an onValueChange handler would  // persist that intermediate size and churn the settings file on every key.  const commitDimension = (axis: "width" | "height", value: number | null) => {    if (value === null || !isValidDimension(value)) return;    const next = { ...presentedSize, [axis]: value };    if (next.width * next.height > PREVIEW_VIEWPORT_MAX_AREA) return;    if (sized && next.width === sized.width && next.height === sized.height) return;    // Typing a size means the preset no longer describes it.    updateSettings({ browserDefaultViewport: { _tag: "freeform", ...next } });  };   return (    <SettingsRow      {...searchableSetting("browser-default-viewport")}      description="Tab size for you and agents. Fill fits the panel; other sizes show the device toolbar."      resetAction={        !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? (          <SettingResetButton            label="default browser viewport"            onClick={() => updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })}          />        ) : null      }      control={        <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">          <Select            value={viewportSelectValue(viewport)}            onValueChange={selectViewport}            disabled={disabled}          >            <SelectTrigger              size="sm"              className="w-full min-w-0 sm:w-44"              aria-label="Default browser viewport"            >              <SelectValue>{viewportSelectLabel(viewport)}</SelectValue>            </SelectTrigger>            <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-64">              <SelectItem value={FILL_VALUE}>Fill panel</SelectItem>              <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem>              <SelectGroup>                <SelectGroupLabel>Standard</SelectGroupLabel>                {PREVIEW_VIEWPORT_PRESETS.map((preset) => (                  <SelectItem key={preset.id} value={preset.id}>                    <span className="flex w-full items-center justify-between gap-5">                      <span>{preset.label}</span>                      <span className="text-xs tabular-nums text-muted-foreground">                        {preset.detail}                      </span>                    </span>                  </SelectItem>                ))}              </SelectGroup>            </SelectPopup>          </Select>           {sized ? (            <div className="flex min-w-0 items-center gap-1">              <NumberField                value={presentedSize.width}                min={PREVIEW_VIEWPORT_MIN_DIMENSION}                max={PREVIEW_VIEWPORT_MAX_DIMENSION}                disabled={disabled}                // Pixel counts read as raw numbers; grouping would show "1,024".                format={NO_GROUPING}                size="sm"                className="w-20"                onValueCommitted={(value) => commitDimension("width", value)}              >                <NumberFieldGroup>                  <NumberFieldInput aria-label="Default viewport width" />                </NumberFieldGroup>              </NumberField>              <span className="text-xs text-muted-foreground">×</span>              <NumberField                value={presentedSize.height}                min={PREVIEW_VIEWPORT_MIN_DIMENSION}                max={PREVIEW_VIEWPORT_MAX_DIMENSION}                disabled={disabled}                format={NO_GROUPING}                size="sm"                className="w-20"                onValueCommitted={(value) => commitDimension("height", value)}              >                <NumberFieldGroup>                  <NumberFieldInput aria-label="Default viewport height" />                </NumberFieldGroup>              </NumberField>              <Tooltip>                <TooltipTrigger                  render={                    <Button                      size="icon-sm"                      variant="ghost-muted"                      disabled={disabled}                      aria-label={`Rotate to ${                        presentedSize.height >= presentedSize.width ? "landscape" : "portrait"                      }`}                      onClick={() =>                        updateSettings({ browserDefaultViewport: rotateViewport(sized) })                      }                    >                      <ScreenRotationIcon />                    </Button>                  }                />                <TooltipPopup side="top">Rotate</TooltipPopup>              </Tooltip>            </div>          ) : null}        </div>      }    />  );} function BrowserZoomSetting({ disabled }: { readonly disabled: boolean }) {  const zoomFactor = useClientSettings((settings) => settings.browserDefaultZoomFactor);  const updateSettings = useUpdatePrimarySettings();   return (    <SettingsRow      {...searchableSetting("browser-default-zoom")}      description="Page zoom applied to new browser tabs."      resetAction={        !disabled && zoomFactor !== DEFAULT_PREVIEW_ZOOM_FACTOR ? (          <SettingResetButton            label="default browser zoom"            onClick={() =>              updateSettings({ browserDefaultZoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR })            }          />        ) : null      }      control={        <Select          disabled={disabled}          value={String(zoomFactor)}          onValueChange={(value) => {            const next = PREVIEW_ZOOM_LEVELS.find((level) => String(level) === value);            if (next !== undefined) updateSettings({ browserDefaultZoomFactor: next });          }}        >          <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Default browser zoom">            <SelectValue>{zoomLabel(zoomFactor)}</SelectValue>          </SelectTrigger>          <SelectPopup align="end" alignItemWithTrigger={false}>            {PREVIEW_ZOOM_LEVELS.map((level) => (              <SelectItem hideIndicator key={level} value={String(level)}>                {zoomLabel(level)}              </SelectItem>            ))}          </SelectPopup>        </Select>      }    />  );} function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean }) {  const appearance = useClientSettings((settings) => settings.browserDefaultAppearance);  const updateSettings = useUpdatePrimarySettings();   return (    <SettingsRow      {...searchableSetting("browser-default-appearance")}      description="The color scheme pages are told to prefer. System follows your OS setting."      resetAction={        !disabled && appearance !== DEFAULT_PREVIEW_APPEARANCE ? (          <SettingResetButton            label="default browser appearance"            onClick={() => updateSettings({ browserDefaultAppearance: DEFAULT_PREVIEW_APPEARANCE })}          />        ) : null      }      control={        <Select          disabled={disabled}          value={appearance}          onValueChange={(value) => {            if (value === "system" || value === "light" || value === "dark") {              updateSettings({ browserDefaultAppearance: value });            }          }}        >          <SelectTrigger            size="sm"            className="w-full sm:w-40"            aria-label="Default browser appearance"          >            <SelectValue>{APPEARANCE_LABELS[appearance]}</SelectValue>          </SelectTrigger>          <SelectPopup align="end" alignItemWithTrigger={false}>            {Object.entries(APPEARANCE_LABELS).map(([value, label]) => (              <SelectItem hideIndicator key={value} value={value}>                {label}              </SelectItem>            ))}          </SelectPopup>        </Select>      }    />  );} function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boolean }) {  const frameRate = useClientSettings((settings) => settings.browserRecordingFrameRate);  const updateSettings = useUpdatePrimarySettings();   return (    <SettingsRow      {...searchableSetting("browser-recording-frame-rate")}      description="Maximum recording rate. 30 fps saves CPU and storage; 60 fps is smoother."      resetAction={        !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? (          <SettingResetButton            label="browser recording frame rate"            onClick={() =>              updateSettings({ browserRecordingFrameRate: DEFAULT_BROWSER_RECORDING_FRAME_RATE })            }          />        ) : null      }      control={        <Select          disabled={disabled}          value={String(frameRate)}          onValueChange={(value) => {            const next = BROWSER_RECORDING_FRAME_RATES.find((rate) => String(rate) === value);            if (next !== undefined) {              updateSettings({ browserRecordingFrameRate: next });            }          }}        >          <SelectTrigger

Lines 1–500 of 1358.