diff --git a/docs/floating-panels.md b/docs/floating-panels.md index bb2ae59f5..7b47b23aa 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it. Two escape hatches in the codebase: -- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so +- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so hit-testing starts fresh in that window. Side effect: a Modal opening on Android can detach the IME from an underlying TextInput. Fine for combobox (it has its own input) and tooltip (no input). **Not** fine for autocomplete @@ -70,7 +70,13 @@ Two escape hatches in the codebase: overlays can use the current `FloatingPanelPortalHost` so sliding sidebars cover them. -Choose Modal vs Portal by whether the underlying input can lose its keyboard. + Choose Modal vs Portal by whether the underlying input can lose its keyboard. + +On web, dropdown menus render into the shared `overlay-root`, not React Native +Web's ``/``. A browser top-layer dialog always paints above +ordinary portals regardless of `z-index`, which would hide app toasts and +tooltips behind the menu. The shared overlay scale keeps menus below toasts and +lets tooltip portals paint above both. ## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index cccafc03c..69507bf80 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -11,6 +11,7 @@ import { type ReactElement, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -33,6 +34,7 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { isWeb } from "@/constants/platform"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; +import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; @@ -496,6 +498,17 @@ export function DropdownMenuContent({ setOpen(false); }, [setOpen]); + useEffect(() => { + if (!isWeb || !modalVisible || typeof window === "undefined") return undefined; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + handleClose(); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [handleClose, modalVisible]); + // Measure trigger when opening useEffect(() => { if (!open || !triggerRef.current) { @@ -613,6 +626,34 @@ export function DropdownMenuContent({ ); + const overlay = ( + + + {!closing + ? renderDropdownSurface({ + frameStyle, + testID, + surfaceStyle, + scrollable, + scrollViewportStyle, + content, + surfaceNativeID, + onExited: () => setModalVisible(false), + }) + : null} + + ); + + if (isWeb && typeof document !== "undefined") { + return createPortal(overlay, getOverlayRoot()); + } + return ( - - - {!closing - ? renderDropdownSurface({ - frameStyle, - testID, - surfaceStyle, - scrollable, - scrollViewportStyle, - content, - surfaceNativeID, - onExited: () => setModalVisible(false), - }) - : null} - + {overlay} ); } @@ -878,6 +899,11 @@ const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1, }, + overlayWeb: { + ...StyleSheet.absoluteFillObject, + zIndex: OVERLAY_Z.modal, + pointerEvents: "auto" as const, + }, backdrop: { position: "absolute", top: 0, diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 4f403cce2..f69c4feab 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -12,6 +12,7 @@ import { type PropsWithChildren, type ReactElement, } from "react"; +import { createPortal } from "react-dom"; import { Dimensions, Platform, @@ -23,13 +24,12 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import { Portal } from "@gorhom/portal"; -import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { FloatingSurface } from "@/components/ui/floating"; import { isWeb } from "@/constants/platform"; +import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; type Side = "top" | "bottom" | "left" | "right"; type Align = "start" | "center" | "end"; @@ -446,7 +446,6 @@ export function TooltipContent({ maxWidth?: number; }>): ReactElement | null { const ctx = useTooltipContext("TooltipContent"); - const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); @@ -516,23 +515,22 @@ export function TooltipContent({ // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the // exact same positioning math as DropdownMenu, without hover feedback loops. if (isWeb) { - return ( - - - - {children} - - - + return createPortal( + + + {children} + + , + getOverlayRoot(), ); } @@ -570,7 +568,7 @@ const styles = StyleSheet.create((theme) => ({ right: 0, bottom: 0, left: 0, - zIndex: 1000, + zIndex: OVERLAY_Z.tooltip, }, content: { paddingVertical: theme.spacing[1], diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index be585dbe0..f9516c371 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -588,19 +588,34 @@ export const ar: TranslationResources = { scripts: { title: "البرامج النصية", actions: { + chooseUrl: "اختيار الرابط", + copyUrl: "نسخ الرابط", + openService: "عرض الخدمة", + restart: "إعادة التشغيل", run: "يجري", - view: "منظر", + stop: "إيقاف", + view: "عرض الوحدة الطرفية", }, accessibility: { trigger: "البرامج النصية Workspace", - openAt: "افتح{{scriptName}}في{{label}}", + openService: "عرض خدمة {{scriptName}}", viewTerminal: "عرض محطة{{scriptName}}", runScript: "قم بتشغيل البرنامج النصي{{scriptName}}", + stopScript: "إيقاف{{scriptName}}", + restartScript: "إعادة تشغيل{{scriptName}}", + copyUrl: "نسخ عنوان URL لـ{{scriptName}}", + chooseUrl: "اختيار رابط {{scriptName}}", script: "البرنامج النصي{{scriptName}}", }, + routes: { + public: "الوكيل العكسي", + paseo: "Memorable", + direct: "مباشر", + }, states: { exitCode: "الخروج من{{code}}", startFailed: "فشل بدء تشغيل{{scriptName}}", + stopFailed: "فشل إيقاف{{scriptName}}", }, }, git: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6c348dd22..412892d42 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -585,19 +585,34 @@ export const en = { scripts: { title: "Scripts", actions: { + chooseUrl: "Choose URL", + copyUrl: "Copy URL", + openService: "View service", + restart: "Restart", run: "Run", - view: "View", + stop: "Stop", + view: "View terminal", }, accessibility: { trigger: "Workspace scripts", - openAt: "Open {{scriptName}} at {{label}}", + openService: "View {{scriptName}} service", viewTerminal: "View {{scriptName}} terminal", runScript: "Run {{scriptName}} script", + stopScript: "Stop {{scriptName}}", + restartScript: "Restart {{scriptName}}", + copyUrl: "Copy {{scriptName}} URL", + chooseUrl: "Choose URL for {{scriptName}}", script: "{{scriptName}} script", }, + routes: { + public: "Reverse proxy", + paseo: "Memorable", + direct: "Direct", + }, states: { exitCode: "exit {{code}}", startFailed: "Failed to start {{scriptName}}", + stopFailed: "Failed to stop {{scriptName}}", }, }, git: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 37a1daffb..b2c333630 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -594,19 +594,34 @@ export const es: TranslationResources = { scripts: { title: "Scripts", actions: { + chooseUrl: "Elegir URL", + copyUrl: "Copiar URL", + openService: "Ver servicio", + restart: "Reiniciar", run: "Correr", - view: "Vista", + stop: "Detener", + view: "Ver terminal", }, accessibility: { trigger: "GuionesWorkspace", - openAt: "Abrir{{scriptName}}en{{label}}", + openService: "Ver servicio {{scriptName}}", viewTerminal: "Ver terminal{{scriptName}}", runScript: "Ejecute el script{{scriptName}}", + stopScript: "Detener{{scriptName}}", + restartScript: "Reiniciar{{scriptName}}", + copyUrl: "Copiar URL de{{scriptName}}", + chooseUrl: "Elegir URL para {{scriptName}}", script: "Guión{{scriptName}}", }, + routes: { + public: "Proxy inverso", + paseo: "Memorable", + direct: "Directa", + }, states: { exitCode: "salir de{{code}}", startFailed: "No se pudo iniciar{{scriptName}}", + stopFailed: "No se pudo detener{{scriptName}}", }, }, git: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5d25d31ad..aa0c82f83 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -594,19 +594,34 @@ export const fr: TranslationResources = { scripts: { title: "Scripts", actions: { + chooseUrl: "Choisir l’URL", + copyUrl: "Copier l’URL", + openService: "Voir le service", + restart: "Redémarrer", run: "Courir", - view: "Voir", + stop: "Arrêter", + view: "Voir le terminal", }, accessibility: { trigger: "ScriptsWorkspace", - openAt: "Ouvrir{{scriptName}}à{{label}}", + openService: "Voir le service {{scriptName}}", viewTerminal: "Voir le terminal{{scriptName}}", runScript: "Exécuter le script{{scriptName}}", + stopScript: "Arrêter{{scriptName}}", + restartScript: "Redémarrer{{scriptName}}", + copyUrl: "Copier l'URL de{{scriptName}}", + chooseUrl: "Choisir l’URL pour {{scriptName}}", script: "Script{{scriptName}}", }, + routes: { + public: "Proxy inverse", + paseo: "Memorable", + direct: "Directe", + }, states: { exitCode: "quitter{{code}}", startFailed: "Échec du démarrage de{{scriptName}}", + stopFailed: "Échec de l'arrêt de{{scriptName}}", }, }, git: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 3be918ea0..491987dcb 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -590,19 +590,34 @@ export const ja: TranslationResources = { scripts: { title: "スクリプト", actions: { + chooseUrl: "URLを選択", + copyUrl: "URLをコピー", + openService: "サービスを表示", + restart: "再起動", run: "実行", - view: "表示", + stop: "停止", + view: "ターミナルを表示", }, accessibility: { trigger: "ワークスペーススクリプト", - openAt: "{{label}}で{{scriptName}}を開く", + openService: "{{scriptName}}サービスを表示", viewTerminal: "{{scriptName}}ターミナルを表示", runScript: "{{scriptName}}スクリプトを実行", + stopScript: "{{scriptName}}を停止", + restartScript: "{{scriptName}}を再起動", + copyUrl: "{{scriptName}}のURLをコピー", + chooseUrl: "{{scriptName}}のURLを選択", script: "{{scriptName}}スクリプト", }, + routes: { + public: "リバースプロキシ", + paseo: "Memorable", + direct: "直接接続", + }, states: { exitCode: "終了コード: {{code}}", startFailed: "{{scriptName}}の起動に失敗しました", + stopFailed: "{{scriptName}}の停止に失敗しました", }, }, git: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f12ad621b..0831981fb 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -591,19 +591,34 @@ export const ptBR: TranslationResources = { scripts: { title: "Scripts", actions: { + chooseUrl: "Escolher URL", + copyUrl: "Copiar URL", + openService: "Ver serviço", + restart: "Reiniciar", run: "Executar", - view: "Ver", + stop: "Parar", + view: "Ver terminal", }, accessibility: { trigger: "Scripts do workspace", - openAt: "Abrir {{scriptName}} em {{label}}", + openService: "Ver serviço {{scriptName}}", viewTerminal: "Ver terminal de {{scriptName}}", runScript: "Executar script {{scriptName}}", + stopScript: "Parar {{scriptName}}", + restartScript: "Reiniciar {{scriptName}}", + copyUrl: "Copiar URL de {{scriptName}}", + chooseUrl: "Escolher URL para {{scriptName}}", script: "script {{scriptName}}", }, + routes: { + public: "Proxy reverso", + paseo: "Memorable", + direct: "Direta", + }, states: { exitCode: "saída {{code}}", startFailed: "Falha ao iniciar {{scriptName}}", + stopFailed: "Falha ao parar {{scriptName}}", }, }, git: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index afb7bf2de..98115495d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -593,19 +593,34 @@ export const ru: TranslationResources = { scripts: { title: "Скрипты", actions: { + chooseUrl: "Выбрать URL", + copyUrl: "Скопировать URL", + openService: "Просмотреть сервис", + restart: "Перезапустить", run: "Бегать", - view: "Вид", + stop: "Остановить", + view: "Открыть терминал", }, accessibility: { trigger: "Скрипты Workspace", - openAt: "Откройте{{scriptName}}на{{label}}", + openService: "Просмотреть сервис {{scriptName}}", viewTerminal: "Посмотреть терминал{{scriptName}}", runScript: "Запустите скрипт{{scriptName}}", + stopScript: "Остановить{{scriptName}}", + restartScript: "Перезапустить{{scriptName}}", + copyUrl: "Скопировать URL {{scriptName}}", + chooseUrl: "Выбрать URL для {{scriptName}}", script: "скрипт{{scriptName}}", }, + routes: { + public: "Обратный прокси", + paseo: "Memorable", + direct: "Прямой адрес", + }, states: { exitCode: "выйти из{{code}}", startFailed: "Не удалось запустить{{scriptName}}", + stopFailed: "Не удалось остановить{{scriptName}}", }, }, git: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index a3ca8dcf3..38e57badb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -584,19 +584,34 @@ export const zhCN: TranslationResources = { scripts: { title: "Scripts", actions: { + chooseUrl: "选择 URL", + copyUrl: "复制 URL", + openService: "查看服务", + restart: "重启", run: "运行", - view: "查看", + stop: "停止", + view: "查看终端", }, accessibility: { trigger: "Workspace scripts", - openAt: "在 {{label}} 打开 {{scriptName}}", + openService: "查看 {{scriptName}} 服务", viewTerminal: "查看 {{scriptName}} Terminal", runScript: "运行 {{scriptName}} script", + stopScript: "停止 {{scriptName}}", + restartScript: "重启 {{scriptName}}", + copyUrl: "复制 {{scriptName}} 的 URL", + chooseUrl: "选择 {{scriptName}} 的 URL", script: "{{scriptName}} script", }, + routes: { + public: "反向代理", + paseo: "Memorable", + direct: "直接地址", + }, states: { exitCode: "exit {{code}}", startFailed: "启动 {{scriptName}} 失败", + stopFailed: "停止 {{scriptName}} 失败", }, }, git: { diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 907688cc3..95b4e4d2c 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -5,6 +5,7 @@ * Z-index scale within overlay root: * - Modal backdrop/content: 10 * - Toast: 20 + * - Tooltip: 30 */ export function getOverlayRoot(): HTMLElement { let el = document.getElementById("overlay-root"); @@ -22,4 +23,5 @@ export function getOverlayRoot(): HTMLElement { export const OVERLAY_Z = { modal: 10, toast: 20, + tooltip: 30, } as const; diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx index c89fe449c..82e0132cd 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx @@ -3,7 +3,7 @@ */ import { i18n as testI18n } from "@/i18n/i18next"; import React, { type ReactElement } from "react"; -import { act } from "@testing-library/react"; +import { act, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,7 +12,16 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-bu void testI18n; -const { theme, startWorkspaceScriptMock } = vi.hoisted(() => { +const { + theme, + startWorkspaceScriptMock, + killTerminalMock, + setStringAsyncMock, + copiedToastMock, + routePreferenceByServerIdMock, + routePreferenceListenersMock, + setPreferredRouteMock, +} = vi.hoisted(() => { const hoistedTheme = { spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 }, borderWidth: { 1: 1 }, @@ -32,9 +41,26 @@ const { theme, startWorkspaceScriptMock } = vi.hoisted(() => { }, }; + const routePreferenceByServerId: Record = {}; + const routePreferenceListeners = new Set<() => void>(); + const setPreferredRoute = vi.fn((serverId: string, kind: "public" | "paseo" | "direct") => { + routePreferenceByServerId[serverId] = kind; + for (const listener of routePreferenceListeners) listener(); + }); + return { theme: hoistedTheme, startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })), + killTerminalMock: vi.fn(async () => ({ + terminalId: "terminal-script-1", + success: true, + requestId: "request-1", + })), + setStringAsyncMock: vi.fn(async () => true), + copiedToastMock: vi.fn(), + routePreferenceByServerIdMock: routePreferenceByServerId, + routePreferenceListenersMock: routePreferenceListeners, + setPreferredRouteMock: setPreferredRoute, }; }); @@ -68,6 +94,25 @@ vi.mock("@/runtime/host-runtime", () => ({ useHostRuntimeSnapshot: () => ({ activeConnection: null }), })); +vi.mock("@/workspace-service-routes/store", async () => { + const ReactModule = await vi.importActual("react"); + const state = { + byServerId: routePreferenceByServerIdMock, + setPreferredRoute: setPreferredRouteMock, + }; + return { + useWorkspaceServiceRoutePreferencesStore: (selector: (value: typeof state) => T) => + ReactModule.useSyncExternalStore( + (listener) => { + routePreferenceListenersMock.add(listener); + return () => routePreferenceListenersMock.delete(listener); + }, + () => selector(state), + () => selector(state), + ), + }; +}); + vi.mock("@/stores/session-store", () => ({ useSessionStore: (selector: (state: unknown) => unknown) => selector({ @@ -75,6 +120,7 @@ vi.mock("@/stores/session-store", () => ({ "test-server": { client: { startWorkspaceScript: startWorkspaceScriptMock, + killTerminal: killTerminalMock, }, }, }, @@ -82,7 +128,11 @@ vi.mock("@/stores/session-store", () => ({ })); vi.mock("@/contexts/toast-context", () => ({ - useToast: () => ({ show: vi.fn(), error: vi.fn() }), + useToast: () => ({ show: vi.fn(), error: vi.fn(), copied: copiedToastMock }), +})); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: setStringAsyncMock, })); vi.mock("@/utils/open-external-url", () => ({ @@ -95,6 +145,22 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
{children}
), DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ + children, + description, + onSelect, + testID, + }: { + children: React.ReactNode; + description?: string; + onSelect?: () => void; + testID?: string; + }) => ( + + ), DropdownMenuTrigger: ({ children, testID, @@ -113,6 +179,14 @@ vi.mock("@/components/ui/dropdown-menu", () => ({ useDropdownMenuClose: () => () => {}, })); +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children as ReactElement, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => children as ReactElement, + TooltipContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => ( +
{children}
+ ), +})); + vi.mock("lucide-react-native", () => { const createIcon = (name: string) => (props: Record) => React.createElement("span", { @@ -123,9 +197,13 @@ vi.mock("lucide-react-native", () => { }); return { ChevronDown: createIcon("ChevronDown"), + Copy: createIcon("Copy"), + Eye: createIcon("Eye"), ExternalLink: createIcon("ExternalLink"), Globe: createIcon("Globe"), Play: createIcon("Play"), + RotateCw: createIcon("RotateCw"), + Square: createIcon("Square"), SquareTerminal: createIcon("SquareTerminal"), }; }); @@ -143,6 +221,8 @@ function script( type: input.type ?? "script", hostname: input.hostname ?? input.scriptName, port: input.port ?? null, + localProxyUrl: input.localProxyUrl, + publicProxyUrl: input.publicProxyUrl, proxyUrl: input.proxyUrl ?? null, lifecycle: input.lifecycle ?? "stopped", health: input.health ?? null, @@ -241,6 +321,13 @@ describe("WorkspaceScriptsButton", () => { ); document.body.innerHTML = ""; startWorkspaceScriptMock.mockClear(); + killTerminalMock.mockClear(); + setStringAsyncMock.mockClear(); + copiedToastMock.mockClear(); + setPreferredRouteMock.mockClear(); + for (const serverId of Object.keys(routePreferenceByServerIdMock)) { + delete routePreferenceByServerIdMock[serverId]; + } }); afterEach(() => { @@ -278,7 +365,7 @@ describe("WorkspaceScriptsButton", () => { expect(icon.dataset.color).toBe(theme.colors.foregroundMuted); expect(row.textContent).toContain("typecheck"); expect(row.textContent).toContain("exit 0"); - expect(row.textContent).toContain("Run"); + expect(row.querySelector('[data-testid="workspace-scripts-start-typecheck"]')).not.toBeNull(); await current.rerender([ script({ @@ -294,7 +381,7 @@ describe("WorkspaceScriptsButton", () => { expect(icon.dataset.icon).toBe("SquareTerminal"); expect(icon.dataset.color).toBe(theme.colors.foregroundMuted); expect(row.textContent).toContain("exit 7"); - expect(row.textContent).toContain("Run"); + expect(row.querySelector('[data-testid="workspace-scripts-start-typecheck"]')).not.toBeNull(); }); it("uses service icon color for service health and running unknown status only", () => { @@ -361,4 +448,173 @@ describe("WorkspaceScriptsButton", () => { const trigger = document.querySelector('[data-testid="workspace-scripts-button"]'); expect(trigger?.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); }); + + it("persists the selected route for the host", () => { + const scripts = [ + script({ + scriptName: "dev", + type: "service", + hostname: "dev--proj--repo.localhost", + lifecycle: "running", + port: 57483, + proxyUrl: "http://dev--proj--repo.localhost:6767", + terminalId: "terminal-script-1", + }), + ]; + current = renderScripts(scripts); + + const row = requireRow("dev"); + expect(row.textContent).toContain("dev--proj--repo.localhost:6767"); + + const routeButton = row.querySelector('[data-testid="workspace-scripts-route-dev"]'); + expect(routeButton).not.toBeNull(); + fireEvent.click( + row.querySelector('[data-testid="workspace-scripts-route-dev-direct"]') as HTMLElement, + ); + expect(setPreferredRouteMock).toHaveBeenCalledWith("test-server", "direct"); + expect(row.textContent).toContain("localhost:57483"); + + const copyButton = row.querySelector('[data-testid="workspace-scripts-copy-dev"]'); + expect(copyButton).not.toBeNull(); + fireEvent.click(copyButton as HTMLElement); + expect(setStringAsyncMock).toHaveBeenCalledWith("http://localhost:57483"); + expect(copiedToastMock).toHaveBeenCalledWith("localhost:57483"); + + current.unmount(); + current = renderScripts(scripts); + expect(requireRow("dev").textContent).toContain("localhost:57483"); + }); + + it("defaults to a configured reverse proxy URL", () => { + current = renderScripts([ + script({ + scriptName: "dev", + type: "service", + lifecycle: "running", + port: 57483, + localProxyUrl: "http://dev--proj--repo.localhost:6767", + publicProxyUrl: "https://dev--proj--repo.services.example.com", + proxyUrl: "https://dev--proj--repo.services.example.com", + terminalId: "terminal-script-1", + }), + ]); + + expect(requireRow("dev").textContent).toContain("dev--proj--repo.services.example.com"); + }); + + it("stops a running script through its terminal", async () => { + current = renderScripts([ + script({ + scriptName: "dev", + lifecycle: "running", + terminalId: "terminal-script-1", + }), + ]); + + const stopButton = requireRow("dev").querySelector( + '[data-testid="workspace-scripts-stop-dev"]', + ); + expect(stopButton).not.toBeNull(); + fireEvent.click(stopButton as HTMLElement); + await act(async () => {}); + + expect(killTerminalMock).toHaveBeenCalledWith("terminal-script-1"); + }); + + it("uses icon-only actions with view and fixed-position lifecycle controls", async () => { + current = renderScripts([ + script({ + scriptName: "dev", + lifecycle: "stopped", + terminalId: "terminal-script-1", + }), + ]); + + let row = requireRow("dev"); + let buttons = Array.from(row.querySelectorAll("button")); + expect(buttons).toHaveLength(1); + expect(buttons.at(-1)?.dataset.testid).toBe("workspace-scripts-start-dev"); + expect(buttons.at(-1)?.querySelector('[data-icon="Play"]')).not.toBeNull(); + expect(buttons.at(-1)?.textContent).toBe(""); + + await current.rerender([ + script({ + scriptName: "dev", + lifecycle: "running", + terminalId: "terminal-script-1", + }), + ]); + + row = requireRow("dev"); + buttons = Array.from(row.querySelectorAll("button")); + expect(buttons.map((button) => button.dataset.testid)).toEqual([ + "workspace-scripts-view-dev", + "workspace-scripts-restart-dev", + "workspace-scripts-stop-dev", + ]); + expect(buttons[0]?.querySelector('[data-icon="SquareTerminal"]')).not.toBeNull(); + expect(buttons.at(-1)?.querySelector('[data-icon="Square"]')).not.toBeNull(); + expect(buttons.every((button) => button.textContent === "")).toBe(true); + }); + + it("adds localized tooltips to every icon action", () => { + current = renderScripts([ + script({ + scriptName: "dev", + type: "service", + lifecycle: "running", + port: 3000, + proxyUrl: "http://dev--project.localhost:6767", + terminalId: "terminal-script-1", + }), + ]); + + expect( + document.querySelector('[data-testid="workspace-scripts-view-dev-tooltip"]')?.textContent, + ).toBe("View terminal"); + expect( + document.querySelector('[data-testid="workspace-scripts-restart-dev-tooltip"]')?.textContent, + ).toBe("Restart"); + expect( + document.querySelector('[data-testid="workspace-scripts-stop-dev-tooltip"]')?.textContent, + ).toBe("Stop"); + expect( + document.querySelector('[data-testid="workspace-scripts-copy-dev-tooltip"]')?.textContent, + ).toBe("Copy URL"); + expect( + document.querySelector('[data-testid="workspace-scripts-route-dev-tooltip"]')?.textContent, + ).toBe("Choose URL"); + }); + + it("restarts a script once its stopped lifecycle arrives", async () => { + current = renderScripts([ + script({ + scriptName: "dev", + lifecycle: "running", + terminalId: "terminal-script-1", + }), + ]); + + const restartButton = requireRow("dev").querySelector( + '[data-testid="workspace-scripts-restart-dev"]', + ); + expect(restartButton).not.toBeNull(); + fireEvent.click(restartButton as HTMLElement); + await act(async () => {}); + + expect(killTerminalMock).toHaveBeenCalledWith("terminal-script-1"); + expect(startWorkspaceScriptMock).not.toHaveBeenCalled(); + + await current.rerender([ + script({ + scriptName: "dev", + lifecycle: "stopped", + exitCode: 0, + terminalId: "terminal-script-1", + }), + ]); + await act(async () => {}); + + expect(startWorkspaceScriptMock).toHaveBeenCalledWith("workspace-1", "dev"); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index d310d759c..7a2a01b1e 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -1,8 +1,18 @@ -import { Fragment, useCallback, useMemo, type ReactElement } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, type ReactElement } from "react"; import type { GestureResponderEvent } from "react-native"; import { Pressable, Text, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; import { useMutation } from "@tanstack/react-query"; -import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native"; +import { + ChevronDown, + Copy, + Eye, + Globe, + Play, + RotateCw, + Square, + SquareTerminal, +} from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import type { WorkspaceDescriptor } from "@/stores/session-store"; @@ -11,17 +21,23 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, useDropdownMenuClose, } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useToast } from "@/contexts/toast-context"; -import { isNative } from "@/constants/platform"; import { openServiceUrl } from "@/utils/open-service-url"; -import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; +import { + resolveWorkspaceScriptLink, + type WorkspaceScriptLinkKind, + type WorkspaceScriptLinkTarget, +} from "@/utils/workspace-script-links"; import type { Theme } from "@/styles/theme"; +import { useWorkspaceServiceRoutePreferencesStore } from "@/workspace-service-routes/store"; -type ScriptActionIcon = "start" | "view"; +type RowActionIcon = "copy" | "open" | "restart" | "start" | "stop" | "terminal"; interface WorkspaceScriptsButtonProps { serverId: string; @@ -35,20 +51,14 @@ interface WorkspaceScriptsButtonProps { presentation?: "split" | "ghost"; } -interface ScriptActionButtonProps { - accessibilityLabel: string; - disabled?: boolean; - icon: ScriptActionIcon; - label: string; - onPress: () => void; - testID: string; -} - const ThemedPlay = withUnistyles(Play); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedExternalLink = withUnistyles(ExternalLink); +const ThemedEye = withUnistyles(Eye); +const ThemedCopy = withUnistyles(Copy); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedSquare = withUnistyles(Square); const GHOST_TRIGGER_ICON_SIZE = 16; @@ -70,43 +80,47 @@ const redColorMapping = (theme: Theme) => ({ const playFillTransparent = { fill: "transparent" }; const ghostPlayStroke = { strokeWidth: 1.5 }; -interface ScriptActionButtonChildrenProps { - hovered?: boolean; - icon: ScriptActionIcon; - label: string; +interface ScriptRowActionButtonProps { + accessibilityLabel: string; + disabled?: boolean; + icon: RowActionIcon; + onPress: () => void; + testID: string; + tooltipLabel: string; } -function ScriptActionButtonChildren({ +function RowActionIconElement({ hovered, icon, - label, -}: ScriptActionButtonChildrenProps): ReactElement { +}: { + hovered?: boolean; + icon: RowActionIcon; +}): ReactElement { const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping; - const iconElement = - icon === "view" ? ( - - ) : ( - - ); - const labelStyle = hovered - ? [styles.actionButtonLabel, styles.actionButtonLabelHovered] - : styles.actionButtonLabel; - return ( - <> - {iconElement} - {label} - - ); + switch (icon) { + case "copy": + return ; + case "open": + return ; + case "restart": + return ; + case "start": + return ; + case "stop": + return ; + case "terminal": + return ; + } } -function ScriptActionButton({ +function ScriptRowActionButton({ accessibilityLabel, disabled, icon, - label, onPress, testID, -}: ScriptActionButtonProps): ReactElement { + tooltipLabel, +}: ScriptRowActionButtonProps): ReactElement { const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); @@ -116,94 +130,204 @@ function ScriptActionButton({ ); const renderChildren = useCallback( - ({ hovered }: { hovered?: boolean }) => ( - - ), - [icon, label], + ({ hovered }: { hovered?: boolean }) => , + [icon], ); return ( - - {renderChildren} - + + + + {renderChildren} + + + + {tooltipLabel} + + ); } -function stripUrlProtocol(url: string): string { - return url.replace(/^https?:\/\//, ""); -} - -interface HostLinkProps { - label: string; - url: string | null; +interface ServiceLinkRowProps { + selectedTarget: WorkspaceScriptLinkTarget; + targets: WorkspaceScriptLinkTarget[]; scriptName: string; - onOpenInBrowserTab?: (url: string) => void; + onSelectKind: (kind: WorkspaceScriptLinkKind) => void; + onCopy: (url: string, label: string) => void; } -interface HostLinkChildrenProps { - hovered?: boolean; - disabled: boolean; +function routeLabelKey( + kind: WorkspaceScriptLinkKind, +): + | "workspace.scripts.routes.public" + | "workspace.scripts.routes.paseo" + | "workspace.scripts.routes.direct" { + switch (kind) { + case "public": + return "workspace.scripts.routes.public"; + case "paseo": + return "workspace.scripts.routes.paseo"; + case "direct": + return "workspace.scripts.routes.direct"; + } +} + +function ServiceRouteOption({ + scriptName, + selectedKind, + target, + onSelect, +}: { + scriptName: string; + selectedKind: WorkspaceScriptLinkKind; + target: WorkspaceScriptLinkTarget; + onSelect: (kind: WorkspaceScriptLinkKind) => void; +}): ReactElement { + const { t } = useTranslation(); + const handleSelect = useCallback(() => onSelect(target.kind), [onSelect, target.kind]); + return ( + + {t(routeLabelKey(target.kind))} + + ); +} + +function ServiceRouteTriggerContent({ + hovered, + label, +}: { + hovered: boolean; label: string; -} - -function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): ReactElement { - const showIcon = !disabled && (hovered || isNative); - const isActive = Boolean(hovered) && !disabled; - const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping; - const hostLabelStyle = isActive ? [styles.hostLabel, styles.hostLabelActive] : styles.hostLabel; +}): ReactElement { return ( <> - + + + + {label} - - {showIcon ? : null} - ); } -function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkProps): ReactElement { +function ServiceRouteSelector({ + scriptName, + selectedTarget, + targets, + onSelect, +}: { + scriptName: string; + selectedTarget: WorkspaceScriptLinkTarget; + targets: WorkspaceScriptLinkTarget[]; + onSelect: (kind: WorkspaceScriptLinkKind) => void; +}): ReactElement { const { t } = useTranslation(); - const disabled = !url; - const closeMenu = useDropdownMenuClose(); - - const handlePress = useCallback( - (event: GestureResponderEvent) => { - event.stopPropagation(); - if (!url) return; - closeMenu(); - void openServiceUrl(url, { openInApp: onOpenInBrowserTab }); - }, - [url, onOpenInBrowserTab, closeMenu], - ); - - const renderChildren = useCallback( - ({ hovered }: { hovered?: boolean }) => ( - - ), - [disabled, label], - ); + const accessibilityLabel = t("workspace.scripts.accessibility.chooseUrl", { scriptName }); return ( - - {renderChildren} - + + + + + + {({ hovered }) => ( + + )} + + + + + {t("workspace.scripts.actions.chooseUrl")} + + + + {targets.map((target) => ( + + ))} + + + ); +} + +function ServiceLinkRow({ + selectedTarget, + targets, + scriptName, + onSelectKind, + onCopy, +}: ServiceLinkRowProps): ReactElement { + const { t } = useTranslation(); + const closeMenu = useDropdownMenuClose(); + const { label, url } = selectedTarget; + + const handleCopy = useCallback(() => { + closeMenu(); + onCopy(url, label); + }, [url, label, onCopy, closeMenu]); + + return ( + + {targets.length > 1 ? ( + + ) : ( + + + + {label} + + + )} + + ); } @@ -218,12 +342,6 @@ function ExitCodeBadge({ code }: { code: number }): ReactElement { ); } -interface HostLink { - key: string; - label: string; - url: string | null; -} - interface ScriptRowProps { script: WorkspaceDescriptor["scripts"][number]; liveTerminalIdSet: Set; @@ -233,7 +351,13 @@ interface ScriptRowProps { : null : null; isStartPending: boolean; + isStopPending: boolean; onStartScript: (scriptName: string) => void; + onStopScript: (scriptName: string) => void; + onRestartScript: (scriptName: string) => void; + onCopyUrl: (url: string, label: string) => void; + preferredRouteKind: WorkspaceScriptLinkKind | null; + onSelectRouteKind: (kind: WorkspaceScriptLinkKind) => void; onViewTerminal?: (terminalId: string) => void; onOpenUrlInBrowserTab?: (url: string) => void; } @@ -259,7 +383,13 @@ function ScriptRow({ liveTerminalIdSet, activeConnection, isStartPending, + isStopPending, onStartScript, + onStopScript, + onRestartScript, + onCopyUrl, + preferredRouteKind, + onSelectRouteKind, onViewTerminal, onOpenUrlInBrowserTab, }: ScriptRowProps): ReactElement { @@ -268,36 +398,24 @@ function ScriptRow({ const isService = (script.type ?? "service") === "service"; const exitCode = script.exitCode ?? null; const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); - const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null; + const selectedLink = + isService && isRunning + ? (serviceLink.targets.find((target) => target.kind === preferredRouteKind) ?? + serviceLink.primary) + : null; const liveTerminalId = script.terminalId && liveTerminalIdSet.has(script.terminalId) ? script.terminalId : null; - const hostLinks: HostLink[] = []; - if (isService && isRunning) { - const routedUrl = script.proxyUrl ?? serviceLink.labelUrl; - if (routedUrl) { - hostLinks.push({ - key: "proxy", - label: stripUrlProtocol(routedUrl), - url: serviceOpenUrl, - }); - } - if (script.port !== null) { - const localhostLabel = `localhost:${script.port}`; - const alreadyShown = hostLinks.some((l) => l.label === localhostLabel); - if (!alreadyShown) { - hostLinks.push({ - key: "localhost", - label: localhostLabel, - url: `http://localhost:${script.port}`, - }); - } - } - } - const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning }); const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal; const showExitBadge = !isRunning && exitCode !== null; + const closeMenu = useDropdownMenuClose(); + + const handleOpenService = useCallback(() => { + if (!selectedLink) return; + closeMenu(); + void openServiceUrl(selectedLink.url, { openInApp: onOpenUrlInBrowserTab }); + }, [selectedLink, closeMenu, onOpenUrlInBrowserTab]); const handleView = useCallback(() => { if (liveTerminalId) onViewTerminal?.(liveTerminalId); @@ -307,38 +425,67 @@ function ScriptRow({ onStartScript(script.scriptName); }, [onStartScript, script.scriptName]); + const handleStop = useCallback(() => { + onStopScript(script.scriptName); + }, [onStopScript, script.scriptName]); + + const handleRestart = useCallback(() => { + onRestartScript(script.scriptName); + }, [onRestartScript, script.scriptName]); + const scriptNameStyle = useMemo( () => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName), [isRunning], ); - let primaryAction: ReactElement | null = null; - if (isRunning && liveTerminalId) { - primaryAction = ( - - ); - } else if (!isRunning) { - primaryAction = ( - - ); - } + ) : null; + + const openServiceAction = selectedLink ? ( + + ) : null; + + const lifecycleAction = isRunning ? ( + + ) : ( + + ); return ( {showExitBadge ? : null} - {primaryAction} + {openServiceAction} + {viewAction} + {isRunning ? ( + + ) : null} + {lifecycleAction} - {hostLinks.length > 0 ? ( + {selectedLink ? ( - {hostLinks.map((link) => ( - - ))} + ) : null} @@ -389,7 +548,14 @@ export function WorkspaceScriptsButton({ const toast = useToast(); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; + const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore( + (state) => state.byServerId[serverId] ?? null, + ); + const setPreferredRoute = useWorkspaceServiceRoutePreferencesStore( + (state) => state.setPreferredRoute, + ); const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]); + const pendingRestartRef = useRef>(new Set()); const startScriptMutation = useMutation({ mutationFn: async (scriptName: string) => { @@ -418,6 +584,46 @@ export function WorkspaceScriptsButton({ } }, }); + const startScript = startScriptMutation.mutate; + + const stopScriptMutation = useMutation({ + mutationFn: async (scriptName: string) => { + if (!client) { + throw new Error(t("common.errors.daemonClientUnavailable")); + } + const terminalId = scripts.find((s) => s.scriptName === scriptName)?.terminalId; + if (!terminalId) { + throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); + } + const result = await client.killTerminal(terminalId); + if (!result.success) { + throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); + } + }, + onError: (error, scriptName) => { + pendingRestartRef.current.delete(scriptName); + toast.show( + error instanceof Error + ? error.message + : t("workspace.scripts.states.stopFailed", { scriptName }), + { + variant: "error", + }, + ); + }, + }); + + // Restart = kill the script terminal, then start again once the daemon + // reports the script as stopped (it tears the runtime entry down on exit). + useEffect(() => { + const pending = pendingRestartRef.current; + if (pending.size === 0) return; + for (const script of scripts) { + if (!pending.has(script.scriptName) || script.lifecycle === "running") continue; + pending.delete(script.scriptName); + startScript(script.scriptName); + } + }, [scripts, startScript]); const triggerStyle = useCallback( ({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [ @@ -433,6 +639,32 @@ export function WorkspaceScriptsButton({ [startScriptMutation], ); + const handleStopScript = useCallback( + (scriptName: string) => stopScriptMutation.mutate(scriptName), + [stopScriptMutation], + ); + + const handleRestartScript = useCallback( + (scriptName: string) => { + pendingRestartRef.current.add(scriptName); + stopScriptMutation.mutate(scriptName); + }, + [stopScriptMutation], + ); + + const handleCopyUrl = useCallback( + (url: string, label: string) => { + void Clipboard.setStringAsync(url); + toast.copied(label); + }, + [toast], + ); + + const handleSelectRouteKind = useCallback( + (kind: WorkspaceScriptLinkKind) => setPreferredRoute(serverId, kind), + [serverId, setPreferredRoute], + ); + if (scripts.length === 0) { return null; } @@ -482,7 +714,13 @@ export function WorkspaceScriptsButton({ liveTerminalIdSet={liveTerminalIdSet} activeConnection={activeConnection} isStartPending={startScriptMutation.isPending} + isStopPending={stopScriptMutation.isPending} onStartScript={handleStartScript} + onStopScript={handleStopScript} + onRestartScript={handleRestartScript} + onCopyUrl={handleCopyUrl} + preferredRouteKind={preferredRouteKind} + onSelectRouteKind={handleSelectRouteKind} onViewTerminal={onViewTerminal} onOpenUrlInBrowserTab={onOpenUrlInBrowserTab} /> @@ -585,10 +823,18 @@ const styles = StyleSheet.create((theme) => ({ hostRow: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1.5], + gap: theme.spacing[2], paddingVertical: 2, minHeight: 18, }, + routeDisplay: { + flex: 1, + flexShrink: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, hostLabel: { flexShrink: 1, fontSize: theme.fontSize.xs, @@ -598,13 +844,6 @@ const styles = StyleSheet.create((theme) => ({ hostLabelActive: { color: theme.colors.foreground, }, - hostIconSlot: { - width: 10, - height: 10, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, exitBadge: { paddingHorizontal: theme.spacing[1.5], paddingVertical: 1, @@ -620,17 +859,28 @@ const styles = StyleSheet.create((theme) => ({ exitBadgeTextError: { color: theme.colors.palette.red[300], }, - actionButton: { + iconActionButton: { + padding: 2, + }, + routeSelectorButton: { + width: 14, + height: 14, + alignItems: "center", + justifyContent: "center", + }, + routeSelectorFrame: { + flex: 1, + minWidth: 0, + }, + routeSelectorTrigger: { + flex: 1, flexDirection: "row", alignItems: "center", - gap: 3, + gap: theme.spacing[2], + minWidth: 0, }, - actionButtonLabel: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - actionButtonLabelHovered: { - color: theme.colors.foreground, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.popoverForeground, }, })); diff --git a/packages/app/src/utils/workspace-script-links.test.ts b/packages/app/src/utils/workspace-script-links.test.ts index e2a3e399e..bc5415b1e 100644 --- a/packages/app/src/utils/workspace-script-links.test.ts +++ b/packages/app/src/utils/workspace-script-links.test.ts @@ -17,33 +17,64 @@ const runningService: WorkspaceScriptPayload = { terminalId: null, }; -function resolveLink(activeConnection: ActiveConnection | null) { - return resolveWorkspaceScriptLink({ - script: runningService, - activeConnection, - }); +function resolveLink( + activeConnection: ActiveConnection | null, + script: WorkspaceScriptPayload = runningService, +) { + return resolveWorkspaceScriptLink({ script, activeConnection }); } describe("resolveWorkspaceScriptLink", () => { - it("uses the local proxy URL for loopback TCP connections", () => { + it("defaults to the memorable Paseo URL locally and keeps direct as a fallback", () => { expect( resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }), ).toEqual({ - openUrl: "http://web--feature--paseo.localhost:6767", - labelUrl: "http://web--feature--paseo.localhost:6767", + primary: { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + targets: [ + { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + { kind: "direct", label: "localhost:3000", url: "http://localhost:3000" }, + ], }); }); - it("uses the local proxy URL for socket and pipe connections", () => { + it("defaults to an explicitly configured reverse proxy", () => { + const publicUrl = "https://web--feature--paseo.services.example.com"; expect( - resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }), + resolveLink( + { type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }, + { ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl }, + ), ).toEqual({ - openUrl: "http://web--feature--paseo.localhost:6767", - labelUrl: "http://web--feature--paseo.localhost:6767", + primary: { + kind: "public", + label: "web--feature--paseo.services.example.com", + url: publicUrl, + }, + targets: [ + { + kind: "public", + label: "web--feature--paseo.services.example.com", + url: publicUrl, + }, + { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + { kind: "direct", label: "localhost:3000", url: "http://localhost:3000" }, + ], }); }); - it("degrades to daemon-host plus service port for direct network connections", () => { + it("uses the daemon host and service port over a direct network connection", () => { expect( resolveLink({ type: "directTcp", @@ -51,124 +82,123 @@ describe("resolveWorkspaceScriptLink", () => { display: "mac-mini.tail123.ts.net:6767", }), ).toEqual({ - openUrl: "http://mac-mini.tail123.ts.net:3000", - labelUrl: "http://mac-mini.tail123.ts.net:3000", - }); - }); - - it("shows the local proxy URL but disables opening over relay", () => { - expect( - resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }), - ).toEqual({ - openUrl: null, - labelUrl: "http://web--feature--paseo.localhost:6767", - }); - }); - - it("opens the public URL over relay when one is provided", () => { - expect( - resolveWorkspaceScriptLink({ - script: { - ...runningService, - publicProxyUrl: "https://web--feature--paseo.services.example.com", - proxyUrl: "https://web--feature--paseo.services.example.com", + primary: { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + targets: [ + { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", }, - activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }, - }), - ).toEqual({ - openUrl: "https://web--feature--paseo.services.example.com", - labelUrl: "https://web--feature--paseo.services.example.com", - }); - }); - - it("uses local URL for direct loopback even when public URL exists", () => { - expect( - resolveWorkspaceScriptLink({ - script: { - ...runningService, - publicProxyUrl: "https://web--feature--paseo.services.example.com", - proxyUrl: "https://web--feature--paseo.services.example.com", + { + kind: "direct", + label: "mac-mini.tail123.ts.net:3000", + url: "http://mac-mini.tail123.ts.net:3000", }, - activeConnection: { type: "directTcp", endpoint: "127.0.0.1:6767", display: "localhost" }, - }), - ).toEqual({ - openUrl: "http://web--feature--paseo.localhost:6767", - labelUrl: "http://web--feature--paseo.localhost:6767", + ], }); }); - it("uses local URL for direct socket and pipe even when public URL exists", () => { + it("offers the reverse proxy and direct route over a direct network connection", () => { + const publicUrl = "https://web--feature--paseo.services.example.com"; expect( - resolveWorkspaceScriptLink({ - script: { - ...runningService, - publicProxyUrl: "https://web--feature--paseo.services.example.com", - proxyUrl: "https://web--feature--paseo.services.example.com", - }, - activeConnection: { type: "directPipe", endpoint: "paseo", display: "pipe" }, - }), - ).toEqual({ - openUrl: "http://web--feature--paseo.localhost:6767", - labelUrl: "http://web--feature--paseo.localhost:6767", - }); + resolveLink( + { type: "directTcp", endpoint: "mac-mini.tail123.ts.net:6767", display: "remote" }, + { ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl }, + ).targets, + ).toEqual([ + { kind: "public", label: "web--feature--paseo.services.example.com", url: publicUrl }, + { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + { + kind: "direct", + label: "mac-mini.tail123.ts.net:3000", + url: "http://mac-mini.tail123.ts.net:3000", + }, + ]); }); - it("uses public URL for direct remote TCP when split URLs exist", () => { - expect( - resolveWorkspaceScriptLink({ - script: { - ...runningService, - publicProxyUrl: "https://web--feature--paseo.services.example.com", - proxyUrl: "https://web--feature--paseo.services.example.com", - }, - activeConnection: { - type: "directTcp", - endpoint: "mac-mini.tail123.ts.net:6767", - display: "remote", - }, - }), - ).toEqual({ - openUrl: "https://web--feature--paseo.services.example.com", - labelUrl: "https://web--feature--paseo.services.example.com", - }); - }); - - it("keeps old daemon local-only proxyUrl payloads working", () => { - const { - localProxyUrl: _localProxyUrl, - publicProxyUrl: _publicProxyUrl, - ...oldPayload - } = runningService; - - expect( - resolveWorkspaceScriptLink({ - script: oldPayload, - activeConnection: { type: "directTcp", endpoint: "localhost:6767", display: "localhost" }, - }), - ).toEqual({ - openUrl: "http://web--feature--paseo.localhost:6767", - labelUrl: "http://web--feature--paseo.localhost:6767", - }); - }); - - it("keeps old daemon public proxyUrl payloads working over relay", () => { - const { - localProxyUrl: _localProxyUrl, - publicProxyUrl: _publicProxyUrl, - ...oldPayload - } = { - ...runningService, - proxyUrl: "https://web--feature--paseo.services.example.com", + it("keeps service routes available independently of a relay connection", () => { + const relay: ActiveConnection = { + type: "relay", + endpoint: "relay.paseo.sh:443", + display: "relay", }; + expect(resolveLink(relay)).toEqual({ + primary: { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + targets: [ + { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + { kind: "direct", label: "localhost:3000", url: "http://localhost:3000" }, + ], + }); + const publicUrl = "https://web--feature--paseo.services.example.com"; expect( - resolveWorkspaceScriptLink({ - script: oldPayload, - activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }, - }), + resolveLink(relay, { ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl }), ).toEqual({ - openUrl: "https://web--feature--paseo.services.example.com", - labelUrl: "https://web--feature--paseo.services.example.com", + primary: { + kind: "public", + label: "web--feature--paseo.services.example.com", + url: publicUrl, + }, + targets: [ + { + kind: "public", + label: "web--feature--paseo.services.example.com", + url: publicUrl, + }, + { + kind: "paseo", + label: "web--feature--paseo.localhost:6767", + url: "http://web--feature--paseo.localhost:6767", + }, + { kind: "direct", label: "localhost:3000", url: "http://localhost:3000" }, + ], + }); + }); + + it("classifies proxyUrl from older daemons", () => { + const { localProxyUrl: _local, publicProxyUrl: _public, ...legacyLocal } = runningService; + expect(resolveLink(null, legacyLocal).targets.map((target) => target.kind)).toEqual([ + "paseo", + "direct", + ]); + + const publicUrl = "https://web--feature--paseo.services.example.com"; + expect( + resolveLink( + { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }, + { ...legacyLocal, proxyUrl: publicUrl }, + ).primary, + ).toEqual({ + kind: "public", + label: "web--feature--paseo.services.example.com", + url: publicUrl, + }); + }); + + it("has no routes for stopped services or plain scripts", () => { + expect(resolveLink(null, { ...runningService, lifecycle: "stopped" })).toEqual({ + primary: null, + targets: [], + }); + expect(resolveLink(null, { ...runningService, type: "script" })).toEqual({ + primary: null, + targets: [], }); }); }); diff --git a/packages/app/src/utils/workspace-script-links.ts b/packages/app/src/utils/workspace-script-links.ts index e7221f19b..301a0f231 100644 --- a/packages/app/src/utils/workspace-script-links.ts +++ b/packages/app/src/utils/workspace-script-links.ts @@ -2,9 +2,17 @@ import { parseHostPort } from "@getpaseo/protocol/daemon-endpoints"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import type { ActiveConnection } from "@/runtime/host-runtime"; +export type WorkspaceScriptLinkKind = "public" | "paseo" | "direct"; + +export interface WorkspaceScriptLinkTarget { + kind: WorkspaceScriptLinkKind; + label: string; + url: string; +} + export interface ResolvedWorkspaceScriptLink { - openUrl: string | null; - labelUrl: string | null; + primary: WorkspaceScriptLinkTarget | null; + targets: WorkspaceScriptLinkTarget[]; } function isLoopbackHost(host: string): boolean { @@ -15,77 +23,70 @@ function isLoopbackHost(host: string): boolean { } function isLocalOnlyUrl(url: string | null | undefined): boolean { - if (!url) { - return true; - } + if (!url) return true; try { - const parsed = new URL(url); - const hostname = parsed.hostname.toLowerCase(); + const hostname = new URL(url).hostname.toLowerCase(); return isLoopbackHost(hostname) || hostname.endsWith(".localhost"); } catch { return true; } } -function buildDirectServiceUrl(endpoint: string, port: number): string | null { +function stripUrlProtocol(url: string): string { + return url.replace(/^https?:\/\//, ""); +} + +function buildDirectServiceUrl( + activeConnection: ActiveConnection | null, + port: number | null, +): string | null { + if (port === null) return null; + if (activeConnection?.type !== "directTcp") { + return `http://localhost:${port}`; + } try { - const { host, isIpv6 } = parseHostPort(endpoint); - const base = isIpv6 ? `[${host}]` : host; + const { host, isIpv6 } = parseHostPort(activeConnection.endpoint); + let base = host; + if (isLoopbackHost(host)) { + base = "localhost"; + } else if (isIpv6) { + base = `[${host}]`; + } return `http://${base}:${port}`; } catch { - return null; + return `http://localhost:${port}`; } } +function addTarget( + targets: WorkspaceScriptLinkTarget[], + kind: WorkspaceScriptLinkKind, + url: string | null | undefined, +): void { + if (!url || targets.some((target) => target.url === url)) return; + targets.push({ kind, label: stripUrlProtocol(url), url }); +} + export function resolveWorkspaceScriptLink(input: { script: WorkspaceScriptPayload; activeConnection: ActiveConnection | null; }): ResolvedWorkspaceScriptLink { const { script, activeConnection } = input; if (script.type !== "service" || script.lifecycle !== "running") { - return { openUrl: null, labelUrl: null }; + return { primary: null, targets: [] }; } - if (!activeConnection) { - return { openUrl: null, labelUrl: script.proxyUrl }; - } - - const localProxyUrl = script.localProxyUrl ?? script.proxyUrl; + // COMPAT(workspaceScriptSplitUrls): added in v0.2.0, remove after 2027-01-21. + // Old daemons only send proxyUrl, so classify it by reachability. + const localProxyUrl = + script.localProxyUrl ?? (isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null); const publicProxyUrl = script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null); - const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl; - if (activeConnection.type === "relay") { - return { - openUrl: publicProxyUrl, - labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl, - }; - } + const targets: WorkspaceScriptLinkTarget[] = []; + addTarget(targets, "public", publicProxyUrl); + addTarget(targets, "paseo", localProxyUrl); + addTarget(targets, "direct", buildDirectServiceUrl(activeConnection, script.port)); - if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") { - return { openUrl: localProxyUrl, labelUrl: localProxyUrl }; - } - - try { - const { host } = parseHostPort(activeConnection.endpoint); - if (isLoopbackHost(host)) { - return { openUrl: localProxyUrl, labelUrl: localProxyUrl }; - } - } catch { - return { openUrl: null, labelUrl: preferredProxyUrl }; - } - - if (publicProxyUrl) { - return { openUrl: publicProxyUrl, labelUrl: publicProxyUrl }; - } - - if (script.port === null) { - return { openUrl: null, labelUrl: script.proxyUrl }; - } - - const directUrl = buildDirectServiceUrl(activeConnection.endpoint, script.port); - return { - openUrl: directUrl, - labelUrl: directUrl ?? preferredProxyUrl, - }; + return { primary: targets[0] ?? null, targets }; } diff --git a/packages/app/src/workspace-service-routes/store.test.ts b/packages/app/src/workspace-service-routes/store.test.ts new file mode 100644 index 000000000..ba2bab1f2 --- /dev/null +++ b/packages/app/src/workspace-service-routes/store.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; +import { createWorkspaceServiceRoutePreferencesStore } from "./store"; + +function createMemoryStorage(initial?: Record): StateStorage & { + values: Map; +} { + const values = new Map(Object.entries(initial ?? {})); + return { + values, + getItem: async (name) => values.get(name) ?? null, + setItem: async (name, value) => { + values.set(name, value); + }, + removeItem: async (name) => { + values.delete(name); + }, + }; +} + +describe("workspace service route preferences", () => { + it("persists each host's preferred route", async () => { + const storage = createMemoryStorage(); + const first = createWorkspaceServiceRoutePreferencesStore(storage); + await first.persist.rehydrate(); + + first.getState().setPreferredRoute("desktop", "direct"); + first.getState().setPreferredRoute("devbox", "public"); + + const restored = createWorkspaceServiceRoutePreferencesStore(storage); + await restored.persist.rehydrate(); + expect(restored.getState().byServerId).toEqual({ desktop: "direct", devbox: "public" }); + }); + + it("drops invalid route kinds from persisted storage", async () => { + const storage = createMemoryStorage({ + "workspace-service-route-preferences": JSON.stringify({ + state: { byServerId: { desktop: "direct", broken: "unknown" } }, + version: 1, + }), + }); + const store = createWorkspaceServiceRoutePreferencesStore(storage); + await store.persist.rehydrate(); + + expect(store.getState().byServerId).toEqual({ desktop: "direct" }); + }); +}); diff --git a/packages/app/src/workspace-service-routes/store.ts b/packages/app/src/workspace-service-routes/store.ts new file mode 100644 index 000000000..15479f08e --- /dev/null +++ b/packages/app/src/workspace-service-routes/store.ts @@ -0,0 +1,50 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links"; + +interface WorkspaceServiceRoutePreferencesState { + byServerId: Record; + setPreferredRoute: (serverId: string, kind: WorkspaceScriptLinkKind) => void; +} + +function isWorkspaceScriptLinkKind(value: unknown): value is WorkspaceScriptLinkKind { + return value === "public" || value === "paseo" || value === "direct"; +} + +function sanitizePreferences(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + const byServerId = (value as { byServerId?: unknown }).byServerId; + if (!byServerId || typeof byServerId !== "object") return {}; + + const result: Record = {}; + for (const [serverId, kind] of Object.entries(byServerId)) { + if (isWorkspaceScriptLinkKind(kind)) result[serverId] = kind; + } + return result; +} + +export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) { + return create()( + persist( + (set) => ({ + byServerId: {}, + setPreferredRoute: (serverId, kind) => + set((state) => ({ byServerId: { ...state.byServerId, [serverId]: kind } })), + }), + { + name: "workspace-service-route-preferences", + version: 1, + storage: createJSONStorage(() => storage), + partialize: (state) => ({ byServerId: state.byServerId }), + merge: (persistedState, currentState) => ({ + ...currentState, + byServerId: sanitizePreferences(persistedState), + }), + }, + ), + ); +} + +export const useWorkspaceServiceRoutePreferencesStore = + createWorkspaceServiceRoutePreferencesStore(AsyncStorage); diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts index b86131401..d82d71210 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts @@ -26,6 +26,7 @@ function makeDescriptor(overrides: { projectKind?: string; workspaceKind?: "directory" | "local_checkout" | "worktree"; name?: string | null; + currentBranch?: string | null; diffStat?: { additions: number; deletions: number } | null; }): WorkspaceDescriptorPayload { return { @@ -34,6 +35,9 @@ function makeDescriptor(overrides: { projectKind: overrides.projectKind ?? "git", workspaceKind: overrides.workspaceKind ?? "local_checkout", name: overrides.name ?? null, + ...(overrides.currentBranch !== undefined + ? { gitRuntime: { currentBranch: overrides.currentBranch } } + : {}), diffStat: overrides.diffStat ?? null, } as unknown as WorkspaceDescriptorPayload; } @@ -259,10 +263,31 @@ describe("shouldSkipUpdate", () => { }); describe("recordDescriptorState", () => { + test("does not treat a workspace title as a branch change", () => { + const h = buildHarness(); + const titledMain = makeDescriptor({ + id: "ws1", + workspaceDirectory: WS1, + name: "Paseo main", + currentBranch: "main", + }); + h.service.syncObservers([titledMain]); + + h.emitSnapshot(WS1, "main"); + h.service.recordDescriptorState("ws1", titledMain); + + expect(h.branchChanges).toEqual([]); + }); + test("fires onBranchChanged once per branch name transition", () => { const h = buildHarness(); h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); - const feature = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "feature" }); + const feature = makeDescriptor({ + id: "ws1", + workspaceDirectory: WS1, + name: "Feature workspace", + currentBranch: "feature", + }); h.service.recordDescriptorState("ws1", feature); h.service.recordDescriptorState("ws1", feature); expect(h.branchChanges).toEqual([["ws1", null, "feature"]]); @@ -296,8 +321,8 @@ describe("teardown", () => { test("keeps a shared cwd subscription until its last workspace is removed", () => { const h = buildHarness(); h.service.syncObservers([ - makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }), - makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "main" }), + makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "Main", currentBranch: "main" }), + makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "Main", currentBranch: "main" }), ]); h.emitSnapshot(WS1, "feature"); diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts index 9a26f8bc4..a5729f2db 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts @@ -28,8 +28,8 @@ interface WorkspaceGitWatchState { * watch without sharing identity or teardown lifetime. * * Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the - * on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop - * (recordDescriptorState). Both stay inside this module so the shared state is coherent. + * on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop's Git runtime + * projection (recordDescriptorState). Both stay inside this module so the shared state is coherent. */ export interface WorkspaceGitObserverService { syncObservers(workspaces: Iterable): void; @@ -92,7 +92,10 @@ export function createWorkspaceGitObserverService(deps: { return; } state.latestDescriptorStateKey = descriptorStateKey(workspace); - state.lastBranchName = workspace?.name ?? null; + const currentBranch = workspace?.gitRuntime?.currentBranch; + if (currentBranch !== undefined) { + state.lastBranchName = currentBranch; + } } function removeForCwd(cwd: string): void { @@ -225,8 +228,8 @@ export function createWorkspaceGitObserverService(deps: { recordDescriptorState(workspaceId, nextWorkspace) { const state = workspaceStates.get(workspaceId); - if (state && onBranchChanged) { - const newBranchName = nextWorkspace?.name ?? null; + const newBranchName = nextWorkspace?.gitRuntime?.currentBranch; + if (state && onBranchChanged && newBranchName !== undefined) { if (newBranchName !== state.lastBranchName) { onBranchChanged(workspaceId, state.lastBranchName, newBranchName); }