| 1 | import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; |
| 4 | import { |
| 5 | threadPullRequestKeysEqual, |
| 6 | visibleThreadPullRequests, |
| 7 | } from "@t3tools/shared/threadPullRequests"; |
| 8 | import type { |
| 9 | ContextMenuItem, |
| 10 | EnvironmentId, |
| 11 | PreviewSessionSnapshot, |
| 12 | ProjectId, |
| 13 | PullRequestState, |
| 14 | ResolvedKeybindingsConfig, |
| 15 | } from "@t3tools/contracts"; |
| 16 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 17 | import { |
| 18 | closestCenter, |
| 19 | DndContext, |
| 20 | type DragEndEvent, |
| 21 | PointerSensor, |
| 22 | useSensor, |
| 23 | useSensors, |
| 24 | } from "@dnd-kit/core"; |
| 25 | import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; |
| 26 | import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; |
| 27 | import { CSS } from "@dnd-kit/utilities"; |
| 28 | import { |
| 29 | Smartphone, |
| 30 | ChevronDown, |
| 31 | ChevronLeft, |
| 32 | ChevronRight, |
| 33 | FileDiff, |
| 34 | Files, |
| 35 | Globe2, |
| 36 | Plus, |
| 37 | TerminalSquare, |
| 38 | RocketIcon, |
| 39 | } from "lucide-react"; |
| 40 | import { Volume2, VolumeOff } from "lucide"; |
| 41 | import { |
| 42 | type ComponentProps, |
| 43 | type KeyboardEvent as ReactKeyboardEvent, |
| 44 | type MouseEvent as ReactMouseEvent, |
| 45 | type ReactElement, |
| 46 | type ReactNode, |
| 47 | useCallback, |
| 48 | useEffect, |
| 49 | useEffectEvent, |
| 50 | useMemo, |
| 51 | useRef, |
| 52 | useState, |
| 53 | } from "react"; |
| 54 | |
| 55 | import { isElectron } from "~/env"; |
| 56 | import type { DesktopPreviewOverlay } from "~/previewStateStore"; |
| 57 | import type { RightPanelSurface } from "~/rightPanelStore"; |
| 58 | import { cn } from "~/lib/utils"; |
| 59 | import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; |
| 60 | import { readLocalApi } from "~/localApi"; |
| 61 | import { Button } from "~/components/ui/button"; |
| 62 | import { MorphIcon } from "~/components/MorphIcon"; |
| 63 | import { AndroidIcon, AppleIcon } from "~/components/Icons"; |
| 64 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 65 | import { Kbd } from "~/components/ui/kbd"; |
| 66 | import { |
| 67 | Menu, |
| 68 | MenuItem, |
| 69 | MenuPopup, |
| 70 | MenuShortcut, |
| 71 | MenuSub, |
| 72 | MenuSubPopup, |
| 73 | MenuSubTrigger, |
| 74 | MenuTrigger, |
| 75 | } from "~/components/ui/menu"; |
| 76 | import { useBrowserDefaults } from "~/browser/browserDefaults"; |
| 77 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 78 | import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; |
| 79 | import { faviconUrlForOrigin } from "~/lib/favicon"; |
| 80 | import { useTheme } from "~/hooks/useTheme"; |
| 81 | import { useDeviceState } from "~/state/device"; |
| 82 | import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize"; |
| 83 | import { |
| 84 | newestPullRequestSummary, |
| 85 | pullRequestEnvironment, |
| 86 | useSharedPullRequestSummary, |
| 87 | } from "~/state/pullRequests"; |
| 88 | import { useEnvironmentQuery } from "~/state/query"; |
| 89 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; |
| 90 | |
| 91 | import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; |
| 92 | import { FaviconImage } from "./preview/PreviewFaviconIcon"; |
| 93 | import { previewBridge } from "./preview/previewBridge"; |
| 94 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 95 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; |
| 96 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 97 | import { observeResize } from "~/lib/observeResize"; |
| 98 | |
| 99 | interface RightPanelTabsProps { |
| 100 | mode: PreviewPanelMode; |
| 101 | maximized?: boolean; |
| 102 | open?: boolean; |
| 103 | keybindings: ResolvedKeybindingsConfig; |
| 104 | getShortcutContext: () => ShortcutMatchContext; |
| 105 | /** Forwarded to PreviewPanelShell so this surface persists its own width. */ |
| 106 | widthStorageKey?: string; |
| 107 | /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ |
| 108 | defaultWidth?: number; |
| 109 | inlineSize?: PreviewPanelInlineSize; |
| 110 | layoutControls?: ReactNode; |
| 111 | surfaces: readonly RightPanelSurface[]; |
| 112 | /** Fallback environment for surfaces that do not carry their own. */ |
| 113 | environmentId: EnvironmentId | null; |
| 114 | activeSurfaceId: string | null; |
| 115 | pendingSurfaceIds: ReadonlySet<string>; |
| 116 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 117 | desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; |
| 118 | /** |
| 119 | * Maps a server session tab id to the desktop runtime tab id the Electron |
| 120 | * preview manager is keyed by. Session ids are only unique within one server |
| 121 | * process, so desktop operations must not be addressed with them. |
| 122 | */ |
| 123 | previewRuntimeTabId?: ((tabId: string) => string) | undefined; |
| 124 | terminalLabelsById: ReadonlyMap<string, string>; |
| 125 | onActivate: (surface: RightPanelSurface) => void; |
| 126 | onRenameDevice?: (surfaceId: string, title: string) => void; |
| 127 | onCloseSurface: (surface: RightPanelSurface) => void; |
| 128 | onCloseOtherSurfaces: (surface: RightPanelSurface) => void; |
| 129 | onCloseSurfacesToRight: (surface: RightPanelSurface) => void; |
| 130 | onCloseAllSurfaces: () => void; |
| 131 | /** Tabs are draggable only when the owner can persist the new order. */ |
| 132 | onMoveSurface?: (surfaceId: string, toIndex: number) => void; |
| 133 | onCopyFilePath: (relativePath: string) => void; |
| 134 | onAddBrowser: () => void; |
| 135 | /** |
| 136 | * Separate from `onAddBrowser` on purpose: that one is passed directly as a |
| 137 | * DOM click handler, and a `(profileId?: string)` signature would silently |
| 138 | * accept the MouseEvent as a profile id. |
| 139 | */ |
| 140 | onAddBrowserInProfile: (profileId: string) => void; |
| 141 | onAddTerminal: () => void; |
| 142 | onAddDiff: () => void; |
| 143 | onAddFiles: () => void; |
| 144 | onAddPullRequest: () => void; |
| 145 | onAddPullRequests: () => void; |
| 146 | onAddDevice: () => void; |
| 147 | browserAvailable: boolean; |
| 148 | terminalAvailable: boolean; |
| 149 | diffAvailable: boolean; |
| 150 | filesAvailable: boolean; |
| 151 | pullRequestAvailable: boolean; |
| 152 | pullRequestsAvailable: boolean; |
| 153 | deviceAvailable: boolean; |
| 154 | pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>; |
| 155 | children: ReactNode; |
| 156 | } |
| 157 | |
| 158 | export interface PullRequestTabStatus { |
| 159 | projectId: string; |
| 160 | repository: string; |
| 161 | number: number; |
| 162 | state: PullRequestState; |
| 163 | isDraft: boolean; |
| 164 | } |
| 165 | |
| 166 | export type PullRequestTabStatusSeed = Pick<PullRequestTabStatus, "state" | "isDraft">; |
| 167 | |
| 168 | export function shouldOpenDefaultBrowserProfileFromMenuClick( |
| 169 | pointerType: string | undefined, |
| 170 | ): boolean { |
| 171 | return pointerType !== "touch"; |
| 172 | } |
| 173 | |
| 174 | const SURFACE_DISABLED_REASONS = { |
| 175 | browser: "Browser previews are only available in The Knife desktop app.", |
| 176 | terminal: "Terminal surfaces are only available from a project thread.", |
| 177 | files: "Files are only available when a project is open.", |
| 178 | diff: "Diff is only available for server threads in Git or Jujutsu workspaces.", |
| 179 | pullRequest: "This thread's branch has no pull request yet.", |
| 180 | pullRequests: "No linked pull requests are available for this thread.", |
| 181 | device: "Devices are only available from a thread.", |
| 182 | } as const; |
| 183 | |
| 184 | /** Overlays that must win over the launcher's letter shortcuts. */ |
| 185 | const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ |
| 186 | '[data-slot="dialog-popup"]', |
| 187 | '[data-slot="alert-dialog-popup"]', |
| 188 | '[data-slot="command-dialog-popup"]', |
| 189 | '[data-slot="menu-popup"]', |
| 190 | '[data-slot="select-popup"]', |
| 191 | '[data-slot="popover-popup"]', |
| 192 | '[data-slot="combobox-popup"]', |
| 193 | '[data-slot="autocomplete-popup"]', |
| 194 | ].join(","); |
| 195 | |
| 196 | /** One-line unavailability hints for the empty-state rows. */ |
| 197 | const SURFACE_UNAVAILABLE_HINTS = { |
| 198 | browser: "Only available in the desktop app.", |
| 199 | terminal: "Available when a project is open.", |
| 200 | files: "Available when a project is open.", |
| 201 | diff: "Available for Git or Jujutsu workspaces.", |
| 202 | pullRequest: "No pull request on this branch yet.", |
| 203 | pullRequests: "No linked pull requests available.", |
| 204 | device: "Available from a thread.", |
| 205 | } as const; |
| 206 | |
| 207 | type TabContextMenuAction = |
| 208 | | "rename" |
| 209 | | "copy-path" |
| 210 | | "toggle-mute" |
| 211 | | "close" |
| 212 | | "close-others" |
| 213 | | "close-to-right" |
| 214 | | "close-all"; |
| 215 | |
| 216 | const TAB_SCROLL_EDGE_TOLERANCE = 1; |
| 217 | const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; |
| 218 | |
| 219 | function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { |
| 220 | return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null; |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Desktop preview tab backing a surface, or null for non-preview surfaces, the |
| 225 | * "new browser tab" placeholder, and the web build where no desktop tab exists. |
| 226 | */ |
| 227 | function previewTabIdOf( |
| 228 | surface: RightPanelSurface, |
| 229 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>, |
| 230 | ): string | null { |
| 231 | if (surface.kind !== "preview" || !surface.resourceId) return null; |
| 232 | return sessions[surface.resourceId]?.tabId ?? null; |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Label and enabled state for a preview tab's mute menu entry. |
| 237 | * Stays disabled until desktop overlay state arrives: a server session id can |
| 238 | * resolve while the preview manager's createTab is still in flight, and muting |
| 239 | * then fails with a PreviewTabNotFoundError nothing surfaces to the user. |
| 240 | */ |
| 241 | export function tabMuteMenuItem(input: { |
| 242 | overlay: DesktopPreviewOverlay | null; |
| 243 | canResolveRuntimeTabId: boolean; |
| 244 | }): { label: string; disabled: boolean } { |
| 245 | const muted = input.overlay?.audioMuted ?? false; |
| 246 | return { |
| 247 | label: muted ? "Unmute tab" : "Mute tab", |
| 248 | disabled: input.overlay === null || !input.canResolveRuntimeTabId, |
| 249 | }; |
| 250 | } |
| 251 | |
| 252 | type TabAudioState = "none" | "audible" | "muted"; |
| 253 | |
| 254 | /** |
| 255 | * A muted tab that is not making sound shows nothing: mute is armed silently, |
| 256 | * and the indicator only appears once there is audio to speak of. |
| 257 | */ |
| 258 | function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { |
| 259 | if (!overlay?.audible) return "none"; |
| 260 | return overlay.audioMuted ? "muted" : "audible"; |
| 261 | } |
| 262 | |
| 263 | type SurfaceShortcutEvent = Pick< |
| 264 | KeyboardEvent, |
| 265 | "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" |
| 266 | >; |
| 267 | |
| 268 | export function surfaceShortcutActionForKey< |
| 269 | const Action extends { available: boolean; shortcut: string }, |
| 270 | >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { |
| 271 | if (event.defaultPrevented || event.isComposing) return null; |
| 272 | if (event.metaKey || event.ctrlKey || event.altKey) return null; |
| 273 | return ( |
| 274 | actions.find( |
| 275 | (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), |
| 276 | ) ?? null |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * A focused editable is a typing context whether or not it has text yet: an |
| 282 | * empty chat composer at rest is still where the user's next keystrokes are |
| 283 | * meant to land, and claiming launcher letters from it would redirect prompts |
| 284 | * into whatever surface opens. The `:not` clause lets `closest` see past |
| 285 | * non-editable islands (`contenteditable="false"`) to an editable host around |
| 286 | * them, matching ComposerPendingUserInputPanel's typing guard. |
| 287 | */ |
| 288 | export function surfaceShortcutTargetsTypingContext( |
| 289 | target: { closest(selectors: string): unknown } | null, |
| 290 | ): boolean { |
| 291 | return ( |
| 292 | target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != |
| 293 | null |
| 294 | ); |
| 295 | } |
| 296 | |
| 297 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { |
| 298 | return ( |
| 299 | <Tooltip> |
| 300 | <TooltipTrigger render={props.trigger} /> |
| 301 | <TooltipPopup side="top">{props.reason}</TooltipPopup> |
| 302 | </Tooltip> |
| 303 | ); |
| 304 | } |
| 305 | |
| 306 | function SurfaceMenuItem(props: { |
| 307 | available: boolean; |
| 308 | disabledReason?: string; |
| 309 | shortcut: string; |
| 310 | onClick: () => void; |
| 311 | children: ReactNode; |
| 312 | }) { |
| 313 | const item = ( |
| 314 | <MenuItem |
| 315 | className={!props.available ? "data-disabled:pointer-events-auto" : undefined} |
| 316 | onClick={props.onClick} |
| 317 | disabled={!props.available} |
| 318 | aria-keyshortcuts={props.shortcut} |
| 319 | > |
| 320 | {props.children} |
| 321 | <MenuShortcut>{props.shortcut}</MenuShortcut> |
| 322 | </MenuItem> |
| 323 | ); |
| 324 | if (props.available || !props.disabledReason) return item; |
| 325 | return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * List launcher shown when the right panel has no surfaces. Keyboard-first |
| 330 | * without palette chrome: a surface's letter opens it directly from anywhere |
| 331 | * outside a typing context, and arrows plus Enter work while the launcher is |
| 332 | * focused. The highlight only appears on hover or arrow use. Unavailable |
| 333 | * surfaces stay visible with a one-line reason. |
| 334 | */ |
| 335 | function RightPanelEmptyState(props: { |
| 336 | onAddBrowser: () => void; |
| 337 | onAddBrowserInProfile: (profileId: string) => void; |
| 338 | browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; |
| 339 | onAddTerminal: () => void; |
| 340 | onAddDiff: () => void; |
| 341 | onAddFiles: () => void; |
| 342 | onAddPullRequest: () => void; |
| 343 | onAddPullRequests: () => void; |
| 344 | onAddDevice: () => void; |
| 345 | browserAvailable: boolean; |
| 346 | terminalAvailable: boolean; |
| 347 | diffAvailable: boolean; |
| 348 | filesAvailable: boolean; |
| 349 | pullRequestAvailable: boolean; |
| 350 | pullRequestsAvailable: boolean; |
| 351 | deviceAvailable: boolean; |
| 352 | }) { |
| 353 | // -1 means no highlight: it only appears on hover or arrow use. |
| 354 | const [highlight, setHighlight] = useState(-1); |
| 355 | |
| 356 | const actions = [ |
| 357 | { |
| 358 | label: "Browser", |
| 359 | icon: Globe2, |
| 360 | shortcut: "B", |
| 361 | available: props.browserAvailable, |
| 362 | disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, |
| 363 | onClick: props.onAddBrowser, |
| 364 | }, |
| 365 | { |
| 366 | label: "Terminal", |
| 367 | icon: TerminalSquare, |
| 368 | shortcut: "T", |
| 369 | available: props.terminalAvailable, |
| 370 | disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, |
| 371 | onClick: props.onAddTerminal, |
| 372 | }, |
| 373 | { |
| 374 | label: "Files", |
| 375 | icon: Files, |
| 376 | shortcut: "F", |
| 377 | available: props.filesAvailable, |
| 378 | disabledReason: SURFACE_UNAVAILABLE_HINTS.files, |
| 379 | onClick: props.onAddFiles, |
| 380 | }, |
| 381 | { |
| 382 | label: "Diff", |
| 383 | icon: FileDiff, |
| 384 | shortcut: "D", |
| 385 | available: props.diffAvailable, |
| 386 | disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, |
| 387 | onClick: props.onAddDiff, |
| 388 | }, |
| 389 | { |
| 390 | label: "Pull request", |
| 391 | icon: PullRequestGlyph.pullRequest, |
| 392 | shortcut: "P", |
| 393 | available: props.pullRequestAvailable, |
| 394 | disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, |
| 395 | onClick: props.onAddPullRequest, |
| 396 | }, |
| 397 | { |
| 398 | label: "Linked pull requests", |
| 399 | icon: PullRequestGlyph.link, |
| 400 | shortcut: "L", |
| 401 | available: props.pullRequestsAvailable, |
| 402 | disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, |
| 403 | onClick: props.onAddPullRequests, |
| 404 | }, |
| 405 | { |
| 406 | label: "Device", |
| 407 | description: "Watch an iOS Simulator or Android Emulator.", |
| 408 | icon: Smartphone, |
| 409 | shortcut: "M", |
| 410 | available: props.deviceAvailable, |
| 411 | disabledReason: SURFACE_UNAVAILABLE_HINTS.device, |
| 412 | onClick: props.onAddDevice, |
| 413 | }, |
| 414 | ] as const; |
| 415 | |
| 416 | type SurfaceAction = (typeof actions)[number]; |
| 417 | |
| 418 | const availableActions = actions.filter((action) => action.available); |
| 419 | const highlightIndex = |
| 420 | availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1); |
| 421 | |
| 422 | // Letter shortcuts work while the launcher is visible, not only while it |
| 423 | // is focused; focus moves around too easily (stray clicks) to carry them. |
| 424 | // Capture phase so app-level key handlers cannot swallow the event first; |
| 425 | // typing contexts and already-handled events are left alone. |
| 426 | const shortcutActionsRef = useRef(availableActions); |
| 427 | useEffect(() => { |
| 428 | shortcutActionsRef.current = availableActions; |
| 429 | }); |
| 430 | useEffect(() => { |
| 431 | const handler = (event: KeyboardEvent) => { |
| 432 | const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); |
| 433 | if (!action) return; |
| 434 | if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; |
| 435 | // The composed path starts at the real target, which may sit inside a shadow root. |
| 436 | const target = event.composedPath()[0] ?? event.target; |
| 437 | if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; |
| 438 | event.preventDefault(); |
| 439 | event.stopPropagation(); |
| 440 | action.onClick(); |
| 441 | }; |
| 442 | window.addEventListener("keydown", handler, true); |
| 443 | return () => window.removeEventListener("keydown", handler, true); |
| 444 | }, []); |
| 445 | |
| 446 | const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => { |
| 447 | if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; |
| 448 | if (availableActions.length === 0) return; |
| 449 | if (event.key === "ArrowDown" || event.key === "ArrowRight") { |
| 450 | event.preventDefault(); |
| 451 | setHighlight((highlightIndex + 1) % availableActions.length); |
| 452 | return; |
| 453 | } |
| 454 | if (event.key === "ArrowUp" || event.key === "ArrowLeft") { |
| 455 | event.preventDefault(); |
| 456 | setHighlight( |
| 457 | highlightIndex === -1 |
| 458 | ? availableActions.length - 1 |
| 459 | : (highlightIndex - 1 + availableActions.length) % availableActions.length, |
| 460 | ); |
| 461 | return; |
| 462 | } |
| 463 | if (event.key === "Enter") { |
| 464 | // Only activate the highlight when the launcher itself has focus. |
| 465 | if (event.target !== event.currentTarget) return; |
| 466 | const action = availableActions[highlightIndex]; |
| 467 | if (!action) return; |
| 468 | event.preventDefault(); |
| 469 | action.onClick(); |
| 470 | } |
| 471 | }; |
| 472 | |
| 473 | // Stable identity so React only runs this callback ref on mount/unmount; |
| 474 | // an inline arrow would re-attach and re-focus on every render. |
| 475 | const focusOnMount = useCallback((node: HTMLDivElement | null) => { |
| 476 | node?.focus(); |
| 477 | }, []); |
| 478 | |
| 479 | const isHighlighted = (action: SurfaceAction) => |
| 480 | highlightIndex !== -1 && availableActions[highlightIndex] === action; |
| 481 | |
| 482 | const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => { |
| 483 | const Icon = action.icon; |
| 484 | return ( |
| 485 | <span className="relative inline-flex shrink-0"> |
| 486 | <Icon className={iconClassName} /> |
| 487 | </span> |
| 488 | ); |
| 489 | }; |
| 490 | |
| 491 | return ( |
| 492 | <div |
| 493 | ref={focusOnMount} |
| 494 | tabIndex={0} |
| 495 | onKeyDown={handleKeyDown} |
| 496 | aria-label="Open a surface" |
| 497 | data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")} |
| 498 | className={cn( |
| 499 | "scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none", |
| 500 | // The panel topbar sits above this container; matching bottom padding |
| 501 | // keeps the list centered against the full panel, not the leftover. |
| 502 | "pb-(--workspace-topbar-height)", |
| 503 | )} |
| 504 | > |
| 505 | <div className="w-full max-w-xs py-6"> |
| 506 | <h3 className="mb-3 text-center font-medium text-foreground text-sm">Open a surface</h3> |
| 507 | <div className="flex flex-col gap-0.5"> |
| 508 | {actions.map((action) => |
| 509 | action.available ? ( |
| 510 | // The row is itself a button, so the profile chooser sits beside |
| 511 | // it in a wrapper rather than inside it. Hover lives on the |
| 512 | // wrapper: the chooser overlays the row, and a pointer moving |
| 513 | // onto it must not read as leaving the row. |
| 514 | <div |
| 515 | key={action.label} |
| 516 | className="group relative" |
| 517 | onMouseEnter={() => setHighlight(availableActions.indexOf(action))} |
| 518 | onMouseLeave={() => |
| 519 | setHighlight((current) => |
| 520 | current === availableActions.indexOf(action) ? -1 : current, |
| 521 | ) |
| 522 | } |
| 523 | > |
| 524 | <button |
| 525 | type="button" |
| 526 | onClick={action.onClick} |
| 527 | className={cn( |
| 528 | "flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-(--control-radius) px-2.5 text-left text-sm transition-colors group-hover:bg-accent/60", |
| 529 | isHighlighted(action) && "bg-accent/60", |
| 530 | )} |
| 531 | > |
| 532 | {actionIcon(action, "size-4")} |
| 533 | <span |
| 534 | className={cn( |
| 535 | "min-w-0 flex-1 truncate", |
| 536 | action.label === "Browser" && props.browserProfiles.length > 1 && "pr-7", |
| 537 | )} |
| 538 | > |
| 539 | {action.label} |
| 540 | </span> |
| 541 | <Kbd>{action.shortcut}</Kbd> |
| 542 | </button> |
| 543 | {/* |
| 544 | Same choice the tab bar's "+" menu offers: the row opens the |
| 545 | default profile, the chevron picks another. Only worth showing |
| 546 | once there is something to choose between. |
| 547 | */} |
| 548 | {action.label === "Browser" && props.browserProfiles.length > 1 ? ( |
| 549 | <Menu> |
| 550 | <MenuTrigger |
| 551 | render={ |
| 552 | <Button |
| 553 | aria-label="Open browser in a profile" |
| 554 | className="absolute top-1/2 right-8 -translate-y-1/2" |
| 555 | size="icon-xs" |
| 556 | variant="ghost-muted" |
| 557 | /> |
| 558 | } |
| 559 | > |
| 560 | <ChevronDown className="size-3.5" /> |
| 561 | </MenuTrigger> |
| 562 | <MenuPopup align="end" side="bottom" sideOffset={6} className="max-w-56"> |
| 563 | {props.browserProfiles.map((profile) => ( |
| 564 | <MenuItem |
| 565 | key={profile.id} |
| 566 | onClick={() => props.onAddBrowserInProfile(profile.id)} |
| 567 | > |
| 568 | <span className="min-w-0 truncate">{profile.name}</span> |
| 569 | </MenuItem> |
| 570 | ))} |
| 571 | </MenuPopup> |
| 572 | </Menu> |
| 573 | ) : null} |
| 574 | </div> |
| 575 | ) : ( |
| 576 | <DisabledReasonTooltip |
| 577 | key={action.label} |
| 578 | reason={action.disabledReason} |
| 579 | trigger={ |
| 580 | <div |
| 581 | tabIndex={0} |
| 582 | aria-disabled="true" |
| 583 | className="flex h-8 w-full cursor-default items-center gap-2.5 rounded-(--control-radius) px-2.5 text-left text-sm opacity-50" |
| 584 | > |
| 585 | {actionIcon(action, "size-4")} |
| 586 | <span className="min-w-0 flex-1 truncate">{action.label}</span> |
| 587 | <Kbd>{action.shortcut}</Kbd> |
| 588 | </div> |
| 589 | } |
| 590 | /> |
| 591 | ), |
| 592 | )} |
| 593 | </div> |
| 594 | </div> |
| 595 | </div> |
| 596 | ); |
| 597 | } |
| 598 | |
| 599 | function surfaceTitle( |
| 600 | surface: RightPanelSurface, |
| 601 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>, |
| 602 | terminalLabelsById: ReadonlyMap<string, string>, |
| 603 | ): string { |
| 604 | switch (surface.kind) { |
| 605 | case "diff": |
| 606 | return "Diff"; |
| 607 | case "files": |
| 608 | return "Files"; |
| 609 | case "file": |
| 610 | return surface.relativePath.slice( |
| 611 | Math.max(surface.relativePath.lastIndexOf("/"), surface.relativePath.lastIndexOf("\\")) + 1, |
| 612 | ); |
| 613 | case "terminal": |
| 614 | return ( |
| 615 | terminalLabelsById.get(surface.activeTerminalId) ?? |
| 616 | getTerminalLabel(surface.activeTerminalId) |
| 617 | ); |
| 618 | case "pull-request": |
| 619 | return `#${surface.number}`; |
| 620 | case "pull-requests": |
| 621 | return "Pull requests"; |
| 622 | case "deployments": |
| 623 | return "Deployments"; |
| 624 | case "device": |
| 625 | return surface.title ?? surface.target?.name ?? "Device"; |
| 626 | case "preview": { |
| 627 | const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; |
| 628 | if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; |
| 629 | if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; |
| 630 | try { |
| 631 | return new URL(snapshot.navStatus.url).host || "Browser"; |
| 632 | } catch { |
| 633 | return "Browser"; |
| 634 | } |
| 635 | } |
| 636 | } |
| 637 | } |
| 638 | |
| 639 | function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { |
| 640 | const publicProviderUrl = faviconUrlForOrigin(url, 32); |
| 641 | return ( |
| 642 | <FaviconImage |
| 643 | sources={[capturedUrl, publicProviderUrl]} |
| 644 | fallback={<Globe2 className="size-3 shrink-0" />} |
| 645 | className="size-3 shrink-0 rounded-sm object-contain" |
| 646 | /> |
| 647 | ); |
| 648 | } |
| 649 | |
| 650 | function sameOrigin(left: string, right: string): boolean { |
| 651 | try { |
| 652 | return new URL(left).origin === new URL(right).origin; |
| 653 | } catch { |
| 654 | return false; |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | function SurfaceIcon({ |
| 659 | surface, |
| 660 | sessions, |
| 661 | desktopByTabId, |
| 662 | theme, |
| 663 | environmentId, |
| 664 | pullRequestStatusSeeds, |
| 665 | }: { |
| 666 | surface: RightPanelSurface; |
| 667 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 668 | desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; |
| 669 | theme: "light" | "dark"; |
| 670 | environmentId: EnvironmentId | null; |
| 671 | pullRequestStatusSeeds: Readonly<Record<string, PullRequestTabStatusSeed>> | undefined; |
| 672 | }) { |
| 673 | switch (surface.kind) { |
| 674 | case "preview": { |
| 675 | const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; |
| 676 | const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; |
| 677 | const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null; |
| 678 | const capturedUrl = |
| 679 | favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; |
| 680 | return <PreviewFavicon capturedUrl={capturedUrl} url={url} />; |
| 681 | } |
| 682 | case "diff": |
| 683 | return <FileDiff className="size-3 shrink-0" />; |
| 684 | case "files": |
| 685 | return <Files className="size-3 shrink-0" />; |
| 686 | case "file": |
| 687 | return ( |
| 688 | <PierreEntryIcon |
| 689 | pathValue={surface.relativePath} |
| 690 | kind="file" |
| 691 | theme={theme} |
| 692 | className="size-3" |
| 693 | /> |
| 694 | ); |
| 695 | case "terminal": |
| 696 | return <TerminalSquare className="size-3 shrink-0" />; |
| 697 | case "pull-request": |
| 698 | return ( |
| 699 | <PullRequestSurfaceIcon |
| 700 | surface={surface} |
| 701 | environmentId={environmentId} |
| 702 | seed={pullRequestStatusSeeds?.[surface.id]} |
| 703 | /> |
| 704 | ); |
| 705 | case "pull-requests": |
| 706 | return <PullRequestGlyph.link className="size-3 shrink-0" />; |
| 707 | case "deployments": |
| 708 | return <RocketIcon className="size-3 shrink-0" />; |
| 709 | case "device": |
| 710 | return surface.target?.platform === "ios" ? ( |
| 711 | <AppleIcon className="size-3 shrink-0" /> |
| 712 | ) : surface.target?.platform === "android" ? ( |
| 713 | <AndroidIcon className="size-3 shrink-0" /> |
| 714 | ) : ( |
| 715 | <Smartphone className="size-3 shrink-0" /> |
| 716 | ); |
| 717 | } |
| 718 | } |
| 719 | |
| 720 | export function resolvePullRequestTabLink( |
| 721 | threads: readonly Pick<EnvironmentThreadShell, "environmentId" | "pullRequests">[], |
| 722 | environmentId: EnvironmentId | null, |
| 723 | host: string | null, |
| 724 | reference: { repository: string; number: number }, |
| 725 | ) { |
| 726 | if (environmentId === null || host === null) return undefined; |
| 727 | let newest: EnvironmentThreadShell["pullRequests"][number] | undefined; |
| 728 | for (const thread of threads) { |
| 729 | if (thread.environmentId !== environmentId) continue; |
| 730 | for (const link of visibleThreadPullRequests(thread.pullRequests)) { |
| 731 | if ( |
| 732 | !threadPullRequestKeysEqual(link, { |
| 733 | host, |
| 734 | repository: reference.repository, |
| 735 | number: reference.number, |
| 736 | }) |
| 737 | ) |
| 738 | continue; |
| 739 | if ( |
| 740 | newest === undefined || |
| 741 | (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "") |
| 742 | ) |
| 743 | newest = link; |
| 744 | } |
| 745 | } |
| 746 | return newest; |
| 747 | } |
| 748 | |
| 749 | function PullRequestSurfaceIcon({ |
| 750 | surface, |
| 751 | environmentId, |
| 752 | seed, |
| 753 | }: { |
| 754 | surface: Extract<RightPanelSurface, { kind: "pull-request" }>; |
| 755 | environmentId: EnvironmentId | null; |
| 756 | seed: PullRequestTabStatusSeed | undefined; |
| 757 | }) { |
| 758 | const resolvedEnvironmentId = |
| 759 | (surface.environmentId as EnvironmentId | undefined) ?? environmentId; |
| 760 | const projects = useProjects(); |
| 761 | const threads = useThreadShells(); |
| 762 | const project = projects.find( |
| 763 | (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, |
| 764 | ); |
| 765 | const identity = project?.repositoryIdentity; |
| 766 | const host = |
| 767 | surface.host ?? |
| 768 | (identity?.provider |
| 769 | ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) |
| 770 | : null); |
| 771 | const configs = useServerConfigs(); |
| 772 | const capabilities = |
| 773 | resolvedEnvironmentId === null |
| 774 | ? undefined |
| 775 | : configs.get(resolvedEnvironmentId)?.environment.capabilities; |
| 776 | const linkedSnapshot = |
| 777 | capabilities?.threadPullRequests === true |
| 778 | ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) |
| 779 | : null; |
| 780 | const detail = useEnvironmentQuery( |
| 781 | resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null |
| 782 | ? null |
| 783 | : pullRequestEnvironment.detail({ |
| 784 | environmentId: resolvedEnvironmentId, |
| 785 | input: { |
| 786 | projectId: surface.projectId as ProjectId, |
| 787 | ...(capabilities?.threadPullRequests === true && surface.host !== undefined |
| 788 | ? { host: surface.host } |
| 789 | : {}), |
| 790 | repository: surface.repository, |
| 791 | number: surface.number, |
| 792 | }, |
| 793 | }), |
| 794 | ).data; |
| 795 | const reference = useMemo( |
| 796 | () => ({ |
| 797 | projectId: surface.projectId as ProjectId, |
| 798 | repository: surface.repository, |
| 799 | number: surface.number, |
| 800 | }), |
| 801 | [surface.projectId, surface.repository, surface.number], |
| 802 | ); |
| 803 | const sharedSummary = useSharedPullRequestSummary(resolvedEnvironmentId, reference, null); |
| 804 | // The compact tab intentionally shows lifecycle and draft state only. Conflict warnings have |
| 805 | // their own presentation on surfaces that have mergeability, while this tab stays stable as |
| 806 | // detail data arrives. |
| 807 | const status = linkedSnapshot ?? newestPullRequestSummary(detail, sharedSummary) ?? seed ?? null; |
| 808 | if (status === null) { |
| 809 | return <PullRequestGlyph.pullRequest className="size-3 shrink-0 text-muted-foreground" />; |
| 810 | } |
| 811 | const presentation = resolvePullRequestState({ |
| 812 | state: status.state, |
| 813 | isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false, |
| 814 | }); |
| 815 | return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />; |
| 816 | } |
| 817 | |
| 818 | /** |
| 819 | * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, |
| 820 | * and the order commits on drop. |
| 821 | */ |
| 822 | function SortableTabList(props: { |
| 823 | surfaces: readonly RightPanelSurface[]; |
| 824 | onMove: ((surfaceId: string, toIndex: number) => void) | undefined; |
| 825 | children: ReactNode; |
| 826 | }) { |
| 827 | const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); |
| 828 | const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); |
| 829 | const { onMove, surfaces } = props; |
| 830 | const handleDragEnd = useCallback( |
| 831 | ({ active, over }: DragEndEvent) => { |
| 832 | if (!over || active.id === over.id) return; |
| 833 | const toIndex = surfaces.findIndex((surface) => surface.id === over.id); |
| 834 | if (toIndex >= 0) onMove?.(String(active.id), toIndex); |
| 835 | }, |
| 836 | [onMove, surfaces], |
| 837 | ); |
| 838 | return ( |
| 839 | <DndContext |
| 840 | sensors={sensors} |
| 841 | collisionDetection={closestCenter} |
| 842 | modifiers={TAB_DRAG_MODIFIERS} |
| 843 | onDragEnd={handleDragEnd} |
| 844 | > |
| 845 | <SortableContext items={surfaceIds} strategy={horizontalListSortingStrategy}> |
| 846 | {props.children} |
| 847 | </SortableContext> |
| 848 | </DndContext> |
| 849 | ); |
| 850 | } |
| 851 | |
| 852 | function SortableTab({ |
| 853 | surfaceId, |
| 854 | disabled, |
| 855 | className, |
| 856 | ...props |
| 857 | }: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { |
| 858 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ |
| 859 | id: surfaceId, |
| 860 | disabled, |
| 861 | }); |
| 862 | return ( |
| 863 | <div |
| 864 | {...props} |
| 865 | {...listeners} |
| 866 | ref={setNodeRef} |
| 867 | style={{ transform: CSS.Translate.toString(transform), transition }} |
| 868 | className={cn(className, isDragging && "relative z-10")} |
| 869 | /> |
| 870 | ); |
| 871 | } |
| 872 | |
| 873 | export function RightPanelTabs(props: RightPanelTabsProps) { |
| 874 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; |
| 875 | const browserProfiles = useBrowserDefaults().profiles; |
| 876 | const { resolvedTheme } = useTheme(); |
| 877 | const tabListRef = useRef<HTMLDivElement>(null); |
| 878 | const addSurfaceTriggerRef = useRef<HTMLButtonElement>(null); |
| 879 | const [renamingDevice, setRenamingDevice] = useState<string | null>(null); |
| 880 | const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); |
| 881 | const [tabScrollState, setTabScrollState] = useState({ |
| 882 | hasOverflow: false, |
| 883 | canScrollLeft: false, |
| 884 | canScrollRight: false, |
| 885 | }); |
| 886 | |
| 887 | if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); |
| 888 | |
| 889 | const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { |
| 890 | if (event.defaultPrevented || event.isComposing) return; |
| 891 | if ( |
| 892 | resolveShortcutCommand(event, props.keybindings, { |
| 893 | context: { ...props.getShortcutContext(), rightPanelOpen: true }, |
| 894 | }) !== "rightPanel.new" |
| 895 | ) |
| 896 | return; |
| 897 | if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; |
| 898 | event.preventDefault(); |
| 899 | event.stopPropagation(); |
| 900 | if (!event.repeat) { |
| 901 | addSurfaceTriggerRef.current?.focus(); |
| 902 | setAddSurfaceMenuOpen(true); |
| 903 | } |
| 904 | }); |
| 905 | useEffect(() => { |
| 906 | if (props.open === false) return; |
| 907 | document.addEventListener("keydown", onNewSurfaceKeyDown, true); |
| 908 | return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); |
| 909 | }, [props.open]); |
| 910 | |
| 911 | const updateTabScrollState = useCallback(() => { |
| 912 | const viewport = tabScrollViewport(tabListRef.current); |
| 913 | if (!viewport) return; |
| 914 | |
| 915 | const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; |
| 916 | const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; |
| 917 | const canScrollRight = |
| 918 | hasOverflow && |
| 919 | viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; |
| 920 | setTabScrollState((current) => { |
| 921 | if ( |
| 922 | current.hasOverflow === hasOverflow && |
| 923 | current.canScrollLeft === canScrollLeft && |
| 924 | current.canScrollRight === canScrollRight |
| 925 | ) { |
| 926 | return current; |
| 927 | } |
| 928 | return { hasOverflow, canScrollLeft, canScrollRight }; |
| 929 | }); |
| 930 | }, []); |
| 931 | |
| 932 | const scrollTabs = useCallback((direction: -1 | 1) => { |
| 933 | const viewport = tabScrollViewport(tabListRef.current); |
| 934 | if (!viewport) return; |
| 935 | const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 936 | viewport.scrollBy({ |
| 937 | left: direction * Math.max(120, viewport.clientWidth * 0.75), |
| 938 | behavior: reduceMotion ? "auto" : "smooth", |
| 939 | }); |
| 940 | }, []); |
| 941 | |
| 942 | const addSurfaceActions = [ |
| 943 | { |
| 944 | label: "Browser", |
| 945 | icon: Globe2, |
| 946 | shortcut: "B", |
| 947 | available: props.browserAvailable, |
| 948 | disabledReason: SURFACE_DISABLED_REASONS.browser, |
| 949 | onClick: props.onAddBrowser, |
| 950 | }, |
| 951 | { |
| 952 | label: "Terminal", |
| 953 | icon: TerminalSquare, |
| 954 | shortcut: "T", |
| 955 | available: props.terminalAvailable, |
| 956 | disabledReason: SURFACE_DISABLED_REASONS.terminal, |
| 957 | onClick: props.onAddTerminal, |
| 958 | }, |
| 959 | { |
| 960 | label: "Files", |
| 961 | icon: Files, |
| 962 | shortcut: "F", |
| 963 | available: props.filesAvailable, |
| 964 | disabledReason: SURFACE_DISABLED_REASONS.files, |
| 965 | onClick: props.onAddFiles, |
| 966 | }, |
| 967 | { |
| 968 | label: "Diff", |
| 969 | icon: FileDiff, |
| 970 | shortcut: "D", |
| 971 | available: props.diffAvailable, |
| 972 | disabledReason: SURFACE_DISABLED_REASONS.diff, |
| 973 | onClick: props.onAddDiff, |
| 974 | }, |
| 975 | { |
| 976 | label: "Pull request", |
| 977 | icon: PullRequestGlyph.pullRequest, |
| 978 | shortcut: "P", |
| 979 | available: props.pullRequestAvailable, |
| 980 | disabledReason: SURFACE_DISABLED_REASONS.pullRequest, |
| 981 | onClick: props.onAddPullRequest, |
| 982 | }, |
| 983 | { |
| 984 | label: "Linked pull requests", |
| 985 | icon: PullRequestGlyph.link, |
| 986 | shortcut: "L", |
| 987 | available: props.pullRequestsAvailable, |
| 988 | disabledReason: SURFACE_DISABLED_REASONS.pullRequests, |
| 989 | onClick: props.onAddPullRequests, |
| 990 | }, |
| 991 | { |
| 992 | label: "Device", |
| 993 | icon: Smartphone, |
| 994 | shortcut: "M", |
| 995 | available: props.deviceAvailable, |
| 996 | disabledReason: SURFACE_DISABLED_REASONS.device, |
| 997 | onClick: props.onAddDevice, |
| 998 | }, |
| 999 | ] as const; |
| 1000 | |
| 1001 | const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => { |
| 1002 | const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); |
| 1003 | if (!action) return; |
| 1004 | event.preventDefault(); |
| 1005 | event.stopPropagation(); |
| 1006 | setAddSurfaceMenuOpen(false); |
| 1007 | action.onClick(); |
| 1008 | }; |
| 1009 | |
| 1010 | const handleTabContextMenu = useCallback( |
| 1011 | async (event: ReactMouseEvent, surface: RightPanelSurface) => { |
| 1012 | event.preventDefault(); |
| 1013 | event.stopPropagation(); |
| 1014 | |
| 1015 | const api = readLocalApi(); |
| 1016 | if (!api) return; |
| 1017 | |
| 1018 | const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); |
| 1019 | if (surfaceIndex < 0) return; |
| 1020 | |
| 1021 | const items: ContextMenuItem<TabContextMenuAction>[] = []; |
| 1022 | if (surface.kind === "device" && props.onRenameDevice) |
| 1023 | items.push({ id: "rename", label: "Rename" }); |
| 1024 | if (surface.kind === "file" && surface.attachment === undefined) { |
| 1025 | items.push({ id: "copy-path", label: "Copy path" }); |
| 1026 | } |
| 1027 | const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); |
| 1028 | // Desktop overlay state only arrives once the preview manager has created |
| 1029 | // the tab. A server session id alone can still be ahead of that, and |
| 1030 | // muting then fails with PreviewTabNotFoundError that nobody surfaces. |
| 1031 | const menuOverlay = menuPreviewTabId |
| 1032 | ? (props.desktopByTabId[menuPreviewTabId] ?? null) |
| 1033 | : null; |
| 1034 | const menuMuted = menuOverlay?.audioMuted ?? false; |
| 1035 | if (surface.kind === "preview") { |
| 1036 | // Not gated on audibility: silencing a quiet tab ahead of time is the |
| 1037 | // point, so the item is offered whenever the tab is mutable at all. |
| 1038 | items.push({ |
| 1039 | id: "toggle-mute", |
| 1040 | ...tabMuteMenuItem({ |
| 1041 | overlay: menuOverlay, |
| 1042 | canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined, |
| 1043 | }), |
| 1044 | }); |
| 1045 | } |
| 1046 | items.push( |
| 1047 | { id: "close", label: "Close" }, |
| 1048 | { |
| 1049 | id: "close-others", |
| 1050 | label: "Close others", |
| 1051 | disabled: props.surfaces.length <= 1, |
| 1052 | }, |
| 1053 | { |
| 1054 | id: "close-to-right", |
| 1055 | label: "Close to the right", |
| 1056 | disabled: surfaceIndex >= props.surfaces.length - 1, |
| 1057 | }, |
| 1058 | { |
| 1059 | id: "close-all", |
| 1060 | label: "Close all", |
| 1061 | disabled: props.surfaces.length === 0, |
| 1062 | }, |
| 1063 | ); |
| 1064 | |
| 1065 | const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); |
| 1066 | switch (action) { |
| 1067 | case "rename": |
| 1068 | setRenamingDevice(surface.id); |
| 1069 | break; |
| 1070 | case "copy-path": |
| 1071 | if (surface.kind === "file" && surface.attachment === undefined) { |
| 1072 | props.onCopyFilePath(surface.relativePath); |
| 1073 | } |
| 1074 | break; |
| 1075 | case "toggle-mute": { |
| 1076 | // menuOverlay repeats the disabled gate above: the desktop tab must |
| 1077 | // exist before it can be addressed, however the menu was dismissed. |
| 1078 | const runtimeTabId = |
| 1079 | menuPreviewTabId && menuOverlay |
| 1080 | ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null) |
| 1081 | : null; |
| 1082 | if (runtimeTabId) { |
| 1083 | void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined); |
| 1084 | } |
| 1085 | break; |
| 1086 | } |
| 1087 | case "close": |
| 1088 | props.onCloseSurface(surface); |
| 1089 | break; |
| 1090 | case "close-others": |
| 1091 | props.onCloseOtherSurfaces(surface); |
| 1092 | break; |
| 1093 | case "close-to-right": |
| 1094 | props.onCloseSurfacesToRight(surface); |
| 1095 | break; |
| 1096 | case "close-all": |
| 1097 | props.onCloseAllSurfaces(); |
| 1098 | break; |
| 1099 | case null: |
| 1100 | break; |
| 1101 | } |
| 1102 | }, |
| 1103 | [props], |
| 1104 | ); |
| 1105 | const handleTabMouseDown = useCallback((event: ReactMouseEvent) => { |
| 1106 | if (event.button !== 1) return; |
| 1107 | event.preventDefault(); |
| 1108 | }, []); |
| 1109 | const handleTabAuxClick = useCallback( |
| 1110 | (event: ReactMouseEvent, surface: RightPanelSurface) => { |
| 1111 | if (event.button !== 1) return; |
| 1112 | event.preventDefault(); |
| 1113 | event.stopPropagation(); |
| 1114 | props.onCloseSurface(surface); |
| 1115 | }, |
| 1116 | [props], |
| 1117 | ); |
| 1118 | |
| 1119 | useEffect(() => { |
| 1120 | if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; |
| 1121 | const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']"); |
| 1122 | activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); |
| 1123 | }, [props.activeSurfaceId, tabScrollState.hasOverflow]); |
| 1124 | |
| 1125 | useEffect(() => { |
| 1126 | const viewport = tabScrollViewport(tabListRef.current); |
| 1127 | if (!viewport) return; |
| 1128 | |
| 1129 | const content = viewport.firstElementChild; |
| 1130 | const stopObserving = observeResize( |
| 1131 | content ? [viewport, content] : viewport, |
| 1132 | updateTabScrollState, |
| 1133 | ); |
| 1134 | viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); |
| 1135 | updateTabScrollState(); |
| 1136 | |
| 1137 | return () => { |
| 1138 | stopObserving(); |
| 1139 | viewport.removeEventListener("scroll", updateTabScrollState); |
| 1140 | }; |
| 1141 | }, [updateTabScrollState]); |
| 1142 | |
| 1143 | useEffect(() => { |
| 1144 | const viewport = tabScrollViewport(tabListRef.current); |
| 1145 | if (!viewport) return; |
| 1146 | |
| 1147 | const handleWheel = (event: WheelEvent) => { |
| 1148 | if (event.ctrlKey) return; |
| 1149 | let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; |
| 1150 | if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; |
| 1151 | if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; |
| 1152 | if (delta === 0) return; |
| 1153 | |
| 1154 | const previousScrollLeft = viewport.scrollLeft; |
| 1155 | viewport.scrollLeft += delta; |
| 1156 | if (viewport.scrollLeft === previousScrollLeft) return; |
| 1157 | event.preventDefault(); |
| 1158 | updateTabScrollState(); |
| 1159 | }; |
| 1160 | |
| 1161 | viewport.addEventListener("wheel", handleWheel, { passive: false }); |
| 1162 | return () => viewport.removeEventListener("wheel", handleWheel); |
| 1163 | }, [updateTabScrollState]); |
| 1164 | |
| 1165 | return ( |
| 1166 | <PreviewPanelShell |
| 1167 | mode={props.mode} |
| 1168 | {...(props.maximized !== undefined ? { maximized: props.maximized } : {})} |
| 1169 | {...(props.open !== undefined ? { open: props.open } : {})} |
| 1170 | {...(props.widthStorageKey !== undefined ? { widthStorageKey: props.widthStorageKey } : {})} |
| 1171 | {...(props.defaultWidth !== undefined ? { defaultWidth: props.defaultWidth } : {})} |
| 1172 | {...(props.inlineSize ? { inlineSize: props.inlineSize } : {})} |
| 1173 | > |
| 1174 | <div |
| 1175 | className={cn( |
| 1176 | "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-1 pl-2", |
| 1177 | // The sheet overlays from the viewport top, so its tab bar keeps |
| 1178 | // the titlebar's height: a compact row re-centers the layout |
| 1179 | // controls a few pixels higher and the cluster jumps on open. |
| 1180 | props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3", |
| 1181 | ownsDesktopTitleBar && "drag-region", |
| 1182 | ownsDesktopTitleBar && "wco:pr-(--workspace-native-controls-inset)", |
| 1183 | props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, |
| 1184 | )} |
| 1185 | data-right-panel-tabbar |
| 1186 | > |
| 1187 | <ScrollArea |
| 1188 | radius="none" |
| 1189 | ref={tabListRef} |
| 1190 | hideScrollbars |
| 1191 | scrollFade |
| 1192 | className="min-w-0 flex-1" |
| 1193 | data-right-panel-tab-list |
| 1194 | > |
| 1195 | <div className="flex h-full w-max min-w-full items-center gap-1"> |
| 1196 | <SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}> |
| 1197 | {props.surfaces.map((surface) => { |
| 1198 | const active = surface.id === props.activeSurfaceId; |
| 1199 | const pending = props.pendingSurfaceIds.has(surface.id); |
| 1200 | const title = surfaceTitle( |
| 1201 | surface, |
| 1202 | props.previewSessions, |
| 1203 | props.terminalLabelsById, |
| 1204 | ); |
| 1205 | const previewTabId = previewTabIdOf(surface, props.previewSessions); |
| 1206 | // Desktop state is keyed by the session id, but desktop actions |
| 1207 | // must be addressed with the runtime id. |
| 1208 | const audio = tabAudioState( |
| 1209 | previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, |
| 1210 | ); |
| 1211 | const audioRuntimeTabId = previewTabId |
| 1212 | ? (props.previewRuntimeTabId?.(previewTabId) ?? null) |
| 1213 | : null; |
| 1214 | return ( |
| 1215 | <SortableTab |
| 1216 | key={surface.id} |
| 1217 | surfaceId={surface.id} |
| 1218 | disabled={!props.onMoveSurface || renamingDevice === surface.id} |
| 1219 | data-active-tab={active} |
| 1220 | onMouseDown={handleTabMouseDown} |
| 1221 | onAuxClick={(event) => handleTabAuxClick(event, surface)} |
| 1222 | onContextMenu={(event) => void handleTabContextMenu(event, surface)} |
| 1223 | className={cn( |
| 1224 | "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", |
| 1225 | ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", |
| 1226 | active |
| 1227 | ? "bg-accent text-foreground" |
| 1228 | : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", |
| 1229 | )} |
| 1230 | > |
| 1231 | <PanelTabCloseButton |
| 1232 | label={`Close ${title}`} |
| 1233 | onClick={() => props.onCloseSurface(surface)} |
| 1234 | > |
| 1235 | <SurfaceIcon |
| 1236 | surface={surface} |
| 1237 | sessions={props.previewSessions} |
| 1238 | desktopByTabId={props.desktopByTabId} |
| 1239 | theme={resolvedTheme} |
| 1240 | environmentId={props.environmentId} |
| 1241 | pullRequestStatusSeeds={props.pullRequestStatusSeeds} |
| 1242 | /> |
| 1243 | {pending ? ( |
| 1244 | <span |
| 1245 | className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current" |
| 1246 | aria-hidden |
| 1247 | /> |
| 1248 | ) : null} |
| 1249 | </PanelTabCloseButton> |
| 1250 | {audio === "none" || !audioRuntimeTabId ? null : ( |
| 1251 | <Tooltip> |
| 1252 | <TooltipTrigger |
| 1253 | render={ |
| 1254 | <button |
| 1255 | type="button" |
| 1256 | className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted" |
| 1257 | aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`} |
| 1258 | onClick={(event) => { |
| 1259 | // Sibling of the close button, inside a tab that |
| 1260 | // activates on click: keep this to the toggle. |
| 1261 | event.stopPropagation(); |
| 1262 | void previewBridge |
| 1263 | ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") |
| 1264 | .catch(() => undefined); |
| 1265 | }} |
| 1266 | > |
| 1267 | <MorphIcon |
| 1268 | className="size-3" |
| 1269 | icon={audio === "muted" ? VolumeOff : Volume2} |
| 1270 | /> |
| 1271 | </button> |
| 1272 | } |
| 1273 | /> |
| 1274 | <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup> |
| 1275 | </Tooltip> |
| 1276 | )} |
| 1277 | {renamingDevice === surface.id ? ( |
| 1278 | <input |
| 1279 | aria-label="Device tab name" |
| 1280 | className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-inset ring-ring" |
| 1281 | defaultValue={title} |
| 1282 | ref={(element) => { |
| 1283 | element?.focus(); |
| 1284 | element?.select(); |
| 1285 | }} |
| 1286 | onBlur={(event) => { |
| 1287 | props.onRenameDevice?.(surface.id, event.currentTarget.value); |
| 1288 | setRenamingDevice(null); |
| 1289 | }} |
| 1290 | onKeyDown={(event) => { |
| 1291 | event.stopPropagation(); |
| 1292 | if (event.key === "Enter") event.currentTarget.blur(); |
| 1293 | if (event.key === "Escape") { |
| 1294 | event.currentTarget.value = title; |
| 1295 | event.currentTarget.blur(); |
| 1296 | } |
| 1297 | }} |
| 1298 | /> |
| 1299 | ) : ( |
| 1300 | <Tooltip> |
| 1301 | <TooltipTrigger |
| 1302 | render={ |
| 1303 | <button |
| 1304 | type="button" |
| 1305 | onDoubleClick={() => { |
| 1306 | if (surface.kind === "device" && props.onRenameDevice) |
| 1307 | setRenamingDevice(surface.id); |
| 1308 | }} |
| 1309 | className="cursor-pointer flex min-w-0 items-center" |
| 1310 | onClick={() => props.onActivate(surface)} |
| 1311 | > |
| 1312 | <span className="truncate">{title}</span> |
| 1313 | </button> |
| 1314 | } |
| 1315 | /> |
| 1316 | <TooltipPopup> |
| 1317 | {surface.kind === "device" ? ( |
| 1318 | <DeviceTabTooltip |
| 1319 | surface={surface} |
| 1320 | environmentId={props.environmentId} |
| 1321 | title={title} |
| 1322 | /> |
| 1323 | ) : ( |
| 1324 | title |
| 1325 | )} |
| 1326 | </TooltipPopup> |
| 1327 | </Tooltip> |
| 1328 | )} |
| 1329 | </SortableTab> |
| 1330 | ); |
| 1331 | })} |
| 1332 | </SortableTabList> |
| 1333 | {props.open !== false ? ( |
| 1334 | <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}> |
| 1335 | <MenuTrigger |
| 1336 | ref={addSurfaceTriggerRef} |
| 1337 | render={ |
| 1338 | <Button |
| 1339 | aria-label="Add panel surface" |
| 1340 | className="shrink-0" |
| 1341 | size="icon-xs" |
| 1342 | variant="ghost-muted" |
| 1343 | /> |
| 1344 | } |
| 1345 | > |
| 1346 | <Plus className="size-3.5" /> |
| 1347 | </MenuTrigger> |
| 1348 | <MenuPopup |
| 1349 | align="start" |
| 1350 | side="bottom" |
| 1351 | sideOffset={6} |
| 1352 | onKeyDownCapture={handleAddSurfaceMenuKeyDown} |
| 1353 | > |
| 1354 | {addSurfaceActions.map((action) => { |
| 1355 | const Icon = action.icon; |
| 1356 | // Browser collapses into one row: clicking the trigger opens |
| 1357 | // the default profile (the common case stays one click), |
| 1358 | // while hover or arrow reveals the profiles. The choice |
| 1359 | // lives at open time because a tab's profile is fixed then — |
| 1360 | // Electron only honours a partition before attach. |
| 1361 | if (action.label === "Browser" && action.available) { |
| 1362 | return ( |
| 1363 | <MenuSub key={action.label}> |
| 1364 | <MenuSubTrigger |
| 1365 | className="[&>svg:last-child]:ms-0" |
| 1366 | aria-keyshortcuts={action.shortcut} |
| 1367 | onClick={(event) => { |
| 1368 | const pointerType = |
| 1369 | "pointerType" in event.nativeEvent && |
| 1370 | typeof event.nativeEvent.pointerType === "string" |
| 1371 | ? event.nativeEvent.pointerType |
| 1372 | : undefined; |
| 1373 | // Touch has no hover path to the profile choices: |
| 1374 | // its first tap opens the submenu, then a profile |
| 1375 | // is selected there. Mouse click keeps the common |
| 1376 | // default-profile action at one click. |
| 1377 | if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) |
| 1378 | return; |
| 1379 | setAddSurfaceMenuOpen(false); |
| 1380 | action.onClick(); |
| 1381 | }} |
| 1382 | > |
| 1383 | <Icon /> |
| 1384 | {action.label} |
| 1385 | <MenuShortcut>{action.shortcut}</MenuShortcut> |
| 1386 | </MenuSubTrigger> |
| 1387 | {/* |
| 1388 | Capped and truncated: profile names are user-supplied |
| 1389 | and run to 48 characters, which would otherwise widen |
| 1390 | the popup to fit-content and wrap. |
| 1391 | */} |
| 1392 | <MenuSubPopup className="max-w-56"> |
| 1393 | {browserProfiles.map((profile) => ( |
| 1394 | <MenuItem |
| 1395 | key={profile.id} |
| 1396 | onClick={() => props.onAddBrowserInProfile(profile.id)} |
| 1397 | > |
| 1398 | <span className="min-w-0 truncate">{profile.name}</span> |
| 1399 | </MenuItem> |
| 1400 | ))} |
| 1401 | </MenuSubPopup> |
| 1402 | </MenuSub> |
| 1403 | ); |
| 1404 | } |
| 1405 | return ( |
| 1406 | <SurfaceMenuItem |
| 1407 | key={action.label} |
| 1408 | available={action.available} |
| 1409 | disabledReason={action.disabledReason} |
| 1410 | shortcut={action.shortcut} |
| 1411 | onClick={action.onClick} |
| 1412 | > |
| 1413 | <Icon /> |
| 1414 | {action.label} |
| 1415 | </SurfaceMenuItem> |
| 1416 | ); |
| 1417 | })} |
| 1418 | </MenuPopup> |
| 1419 | </Menu> |
| 1420 | ) : null} |
| 1421 | </div> |
| 1422 | </ScrollArea> |
| 1423 | {tabScrollState.hasOverflow ? ( |
| 1424 | <div |
| 1425 | className="flex shrink-0 items-center gap-0.5 [-webkit-app-region:no-drag]" |
| 1426 | role="group" |
| 1427 | aria-label="Scroll panel tabs" |
| 1428 | > |
| 1429 | <Tooltip> |
| 1430 | <TooltipTrigger |
| 1431 | render={ |
| 1432 | <span className="inline-flex"> |
| 1433 | <Button |
| 1434 | aria-label="Scroll tabs left" |
| 1435 | disabled={!tabScrollState.canScrollLeft} |
| 1436 | onClick={() => scrollTabs(-1)} |
| 1437 | size="icon-xs" |
| 1438 | variant="ghost" |
| 1439 | > |
| 1440 | <ChevronLeft /> |
| 1441 | </Button> |
| 1442 | </span> |
| 1443 | } |
| 1444 | /> |
| 1445 | <TooltipPopup>Scroll tabs left</TooltipPopup> |
| 1446 | </Tooltip> |
| 1447 | <Tooltip> |
| 1448 | <TooltipTrigger |
| 1449 | render={ |
| 1450 | <span className="inline-flex"> |
| 1451 | <Button |
| 1452 | aria-label="Scroll tabs right" |
| 1453 | disabled={!tabScrollState.canScrollRight} |
| 1454 | onClick={() => scrollTabs(1)} |
| 1455 | size="icon-xs" |
| 1456 | variant="ghost" |
| 1457 | > |
| 1458 | <ChevronRight /> |
| 1459 | </Button> |
| 1460 | </span> |
| 1461 | } |
| 1462 | /> |
| 1463 | <TooltipPopup>Scroll tabs right</TooltipPopup> |
| 1464 | </Tooltip> |
| 1465 | </div> |
| 1466 | ) : null} |
| 1467 | {props.layoutControls} |
| 1468 | {ownsDesktopTitleBar && !props.layoutControls ? ( |
| 1469 | // Keeps the tabs clear of the window controls when the layout toggles live elsewhere. |
| 1470 | <span aria-hidden className="hidden w-24 shrink-0 wco:block" /> |
| 1471 | ) : null} |
| 1472 | {ownsDesktopTitleBar ? ( |
| 1473 | <span |
| 1474 | aria-hidden |
| 1475 | className="pointer-events-none fixed top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] h-[var(--workspace-topbar-height)] w-28 [-webkit-app-region:no-drag]" |
| 1476 | /> |
| 1477 | ) : null} |
| 1478 | </div> |
| 1479 | <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content> |
| 1480 | {props.activeSurfaceId === null ? ( |
| 1481 | <RightPanelEmptyState |
| 1482 | onAddBrowser={props.onAddBrowser} |
| 1483 | onAddBrowserInProfile={props.onAddBrowserInProfile} |
| 1484 | browserProfiles={browserProfiles} |
| 1485 | onAddTerminal={props.onAddTerminal} |
| 1486 | onAddDiff={props.onAddDiff} |
| 1487 | onAddFiles={props.onAddFiles} |
| 1488 | onAddPullRequest={props.onAddPullRequest} |
| 1489 | onAddPullRequests={props.onAddPullRequests} |
| 1490 | onAddDevice={props.onAddDevice} |
| 1491 | browserAvailable={props.browserAvailable} |
| 1492 | terminalAvailable={props.terminalAvailable} |
| 1493 | diffAvailable={props.diffAvailable} |
| 1494 | filesAvailable={props.filesAvailable} |
| 1495 | pullRequestAvailable={props.pullRequestAvailable} |
| 1496 | pullRequestsAvailable={props.pullRequestsAvailable} |
| 1497 | deviceAvailable={props.deviceAvailable} |
| 1498 | /> |
| 1499 | ) : ( |
| 1500 | props.children |
| 1501 | )} |
| 1502 | </div> |
| 1503 | </PreviewPanelShell> |
| 1504 | ); |
| 1505 | } |
| 1506 | |
| 1507 | function DeviceTabTooltip(props: { |
| 1508 | surface: Extract<RightPanelSurface, { kind: "device" }>; |
| 1509 | environmentId: EnvironmentId | null; |
| 1510 | title: string; |
| 1511 | }) { |
| 1512 | const target = props.surface.target; |
| 1513 | const { state } = useDeviceState(target ? props.environmentId : null); |
| 1514 | const device = target |
| 1515 | ? state.devices.find((entry) => entry.hostId === target.hostId && entry.id === target.deviceId) |
| 1516 | : undefined; |
| 1517 | const host = target ? state.hosts.find((entry) => entry.id === target.hostId) : undefined; |
| 1518 | return ( |
| 1519 | <div className="flex flex-col gap-0.5"> |
| 1520 | <span>{props.title}</span> |
| 1521 | {target ? ( |
| 1522 | <span className="text-muted-foreground"> |
| 1523 | {host?.label ?? "Device host"} ·{" "} |
| 1524 | {device?.version ?? (target.platform === "ios" ? "iOS" : "Android")} |
| 1525 | </span> |
| 1526 | ) : null} |
| 1527 | </div> |
| 1528 | ); |
| 1529 | } |