import React, { useEffect, useRef, useState } from 'react'; import { ChevronDown, Check, Copy, MoreHorizontal } from 'lucide-react'; import { AppIcon } from './icons/AppIcon'; import { useOpenInApps, useRememberedOpenInApp } from '../hooks/useOpenInApps'; import { copyTextToClipboard } from '../utils/clipboard'; import { noteServerSessionMismatch, withServerSession } from '../utils/serverSession'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from './ui/dropdown-menu'; /** * OpenInAppButton — split button + file-actions menu. * * When the file is launchable (local session, resolvable path), the left half * opens it in the last-selected app and the chevron drops down the detected * apps (file manager, then editors, then terminals) — picking one persists it * AND opens immediately. The menu also carries Copy path and (review only) * Copy file diff. * * When launching is unavailable (remote session, or PR review with no local * checkout), the app actions are hidden but the copy actions remain behind a * `⋯` overflow button — so e.g. Copy file diff never disappears. Renders * nothing when there is neither an app to open nor a diff to copy. */ interface OpenInAppButtonProps { filePath: string | null | undefined; base?: string | null; /** Diff/patch text for the "Copy file diff" menu action (review only). */ diffText?: string | null; /** * When false, hide the app-launch actions (e.g. PR review with no local * checkout, where files aren't resolvable on disk) but keep copy actions. */ canOpen?: boolean; /** When true, render the primary label text alongside the icon. */ showLabel?: boolean; disabled?: boolean; } // The host app catalog is static for the session, but the all-files view // renders one OpenInAppButton per file — so /api/open-in/apps is fetched once // and the promise shared across every instance (useOpenInApps). const OPEN_IN_APPS_URL = '/api/open-in/apps'; export const OpenInAppButton: React.FC = ({ filePath, base, diffText, canOpen = true, showLabel = false, disabled = false, }) => { const detected = useOpenInApps(OPEN_IN_APPS_URL); const apps = detected?.apps ?? null; const available = detected ? detected.available : null; const [storedId, rememberApp] = useRememberedOpenInApp(); // Nothing picked yet: the file manager, as before. const currentId = storedId ?? 'reveal'; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const errorTimer = useRef | null>(null); useEffect(() => { return () => { if (errorTimer.current) clearTimeout(errorTimer.current); }; }, []); const list = apps ?? []; // Apps are launchable: server says available, the surface allows it, the path // is resolvable, and at least one app was detected. const openable = available === true && canOpen && !!filePath && list.length > 0; // Nothing to do: can't open AND nothing to copy. Also preserve the // "hide when unavailable" behavior for surfaces whose only copy action is the // path (annotate in a remote session) — show the standalone menu only when // there's a diff to copy. if (!openable && !diffText) return null; // Resolve the active app: last-used if still detected, else reveal, else first. const resolvedId = list.some((a) => a.id === currentId) ? currentId : list.some((a) => a.id === 'reveal') ? 'reveal' : (list[0]?.id ?? 'reveal'); const currentApp = list.find((a) => a.id === resolvedId) ?? list[0]; const flashError = (msg: string) => { setError(msg); if (errorTimer.current) clearTimeout(errorTimer.current); errorTimer.current = setTimeout(() => setError(null), 4000); }; const open = async (appId: string) => { if (!filePath || busy) return; setBusy(true); setError(null); try { const res = await fetch('/api/open-in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // The stale-tab guard: a server that took over this port refuses it. body: JSON.stringify(withServerSession({ filePath, base: base ?? null, appId })), }); if (await noteServerSessionMismatch(res)) { flashError('This review was replaced. Reload the page.'); return; } const data = (await res.json().catch(() => null)) as | { ok: boolean; error?: string } | null; if (!res.ok || !data || data.ok === false) { flashError(data?.error || 'Failed to open'); } } catch { flashError('Failed to open'); } finally { setBusy(false); } }; const selectApp = (appId: string) => { rememberApp(appId); setMenuOpen(false); void open(appId); }; const copyText = async (text: string) => { await copyTextToClipboard(text); setMenuOpen(false); }; // Menu groups: file manager first, then editors, then terminals. const grouped = [ list.filter((a) => a.kind === 'file-manager'), list.filter((a) => a.kind === 'editor'), list.filter((a) => a.kind === 'terminal'), ].filter((g) => g.length > 0); const Spinner = ( ); const isDisabled = disabled || busy; return (
{/* Primary: open in the current app (only when launchable). */} {openable && currentApp && ( )} {/* Chevron (with primary) or standalone overflow (copy-only). */} } > {openable ? ( ) : ( )} {openable && grouped.map((group, gi) => ( {gi > 0 && } {group.map((app) => ( selectApp(app.id)} className="text-xs" > {app.label} {app.id === resolvedId && } ))} ))} {openable && (filePath || diffText) && } {filePath && ( void copyText(filePath)} className="text-xs" > Copy path )} {diffText && ( void copyText(diffText)} className="text-xs" > Copy file diff )}
{error && ( {error} )}
); };