feat(app): improve workspace service controls

Keep service route choices independent of daemon transport and persist the selected route per host. Use the shared web overlay stack so menus, tooltips, and toasts layer predictably.

Track proxy route changes from the Git branch rather than the workspace title to avoid transient service health changes.
This commit is contained in:
Mohamed Boudra
2026-07-21 13:44:23 +02:00
parent afeb4d18f8
commit 8aa55db1e8
20 changed files with 1242 additions and 428 deletions
+8 -2
View File
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase: 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 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 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 (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 overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
cover them. 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 `<Modal>`/`<dialog>`. 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 ## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
@@ -11,6 +11,7 @@ import {
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -33,6 +34,7 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
// Action status for menu items with loading/success feedback // Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success"; export type ActionStatus = "idle" | "pending" | "success";
@@ -496,6 +498,17 @@ export function DropdownMenuContent({
setOpen(false); setOpen(false);
}, [setOpen]); }, [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 // Measure trigger when opening
useEffect(() => { useEffect(() => {
if (!open || !triggerRef.current) { if (!open || !triggerRef.current) {
@@ -613,6 +626,34 @@ export function DropdownMenuContent({
</View> </View>
); );
const overlay = (
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
</View>
);
if (isWeb && typeof document !== "undefined") {
return createPortal(overlay, getOverlayRoot());
}
return ( return (
<Modal <Modal
visible={modalVisible} visible={modalVisible}
@@ -622,27 +663,7 @@ export function DropdownMenuContent({
onDismiss={flushPendingSelect} onDismiss={flushPendingSelect}
onRequestClose={handleClose} onRequestClose={handleClose}
> >
<View style={styles.overlay}> {overlay}
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
</View>
</Modal> </Modal>
); );
} }
@@ -878,6 +899,11 @@ const styles = StyleSheet.create((theme) => ({
overlay: { overlay: {
flex: 1, flex: 1,
}, },
overlayWeb: {
...StyleSheet.absoluteFillObject,
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
backdrop: { backdrop: {
position: "absolute", position: "absolute",
top: 0, top: 0,
+19 -21
View File
@@ -12,6 +12,7 @@ import {
type PropsWithChildren, type PropsWithChildren,
type ReactElement, type ReactElement,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { import {
Dimensions, Dimensions,
Platform, Platform,
@@ -23,13 +24,12 @@ import {
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import { FadeIn, FadeOut } from "react-native-reanimated"; import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating"; import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
type Side = "top" | "bottom" | "left" | "right"; type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end"; type Align = "start" | "center" | "end";
@@ -446,7 +446,6 @@ export function TooltipContent({
maxWidth?: number; maxWidth?: number;
}>): ReactElement | null { }>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent"); const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: 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 // steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops. // exact same positioning math as DropdownMenu, without hover feedback loops.
if (isWeb) { if (isWeb) {
return ( return createPortal(
<Portal hostName={bottomSheetInternal?.hostName}> <View pointerEvents="none" style={styles.portalOverlay}>
<View pointerEvents="none" style={styles.portalOverlay}> <FloatingSurface
<FloatingSurface pointerEvents="none"
pointerEvents="none" entering={FadeIn.duration(80)}
entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)}
exiting={FadeOut.duration(80)} collapsable={false}
collapsable={false} testID={testID}
testID={testID} onLayout={handleLayout}
onLayout={handleLayout} style={contentStyle}
style={contentStyle} frameStyle={frameStyle}
frameStyle={frameStyle} >
> {children}
{children} </FloatingSurface>
</FloatingSurface> </View>,
</View> getOverlayRoot(),
</Portal>
); );
} }
@@ -570,7 +568,7 @@ const styles = StyleSheet.create((theme) => ({
right: 0, right: 0,
bottom: 0, bottom: 0,
left: 0, left: 0,
zIndex: 1000, zIndex: OVERLAY_Z.tooltip,
}, },
content: { content: {
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
+17 -2
View File
@@ -588,19 +588,34 @@ export const ar: TranslationResources = {
scripts: { scripts: {
title: "البرامج النصية", title: "البرامج النصية",
actions: { actions: {
chooseUrl: "اختيار الرابط",
copyUrl: "نسخ الرابط",
openService: "عرض الخدمة",
restart: "إعادة التشغيل",
run: "يجري", run: "يجري",
view: "منظر", stop: "إيقاف",
view: "عرض الوحدة الطرفية",
}, },
accessibility: { accessibility: {
trigger: "البرامج النصية Workspace", trigger: "البرامج النصية Workspace",
openAt: "افتح{{scriptName}}في{{label}}", openService: "عرض خدمة {{scriptName}}",
viewTerminal: "عرض محطة{{scriptName}}", viewTerminal: "عرض محطة{{scriptName}}",
runScript: "قم بتشغيل البرنامج النصي{{scriptName}}", runScript: "قم بتشغيل البرنامج النصي{{scriptName}}",
stopScript: "إيقاف{{scriptName}}",
restartScript: "إعادة تشغيل{{scriptName}}",
copyUrl: "نسخ عنوان URL لـ{{scriptName}}",
chooseUrl: "اختيار رابط {{scriptName}}",
script: "البرنامج النصي{{scriptName}}", script: "البرنامج النصي{{scriptName}}",
}, },
routes: {
public: "الوكيل العكسي",
paseo: "Memorable",
direct: "مباشر",
},
states: { states: {
exitCode: "الخروج من{{code}}", exitCode: "الخروج من{{code}}",
startFailed: "فشل بدء تشغيل{{scriptName}}", startFailed: "فشل بدء تشغيل{{scriptName}}",
stopFailed: "فشل إيقاف{{scriptName}}",
}, },
}, },
git: { git: {
+17 -2
View File
@@ -585,19 +585,34 @@ export const en = {
scripts: { scripts: {
title: "Scripts", title: "Scripts",
actions: { actions: {
chooseUrl: "Choose URL",
copyUrl: "Copy URL",
openService: "View service",
restart: "Restart",
run: "Run", run: "Run",
view: "View", stop: "Stop",
view: "View terminal",
}, },
accessibility: { accessibility: {
trigger: "Workspace scripts", trigger: "Workspace scripts",
openAt: "Open {{scriptName}} at {{label}}", openService: "View {{scriptName}} service",
viewTerminal: "View {{scriptName}} terminal", viewTerminal: "View {{scriptName}} terminal",
runScript: "Run {{scriptName}} script", runScript: "Run {{scriptName}} script",
stopScript: "Stop {{scriptName}}",
restartScript: "Restart {{scriptName}}",
copyUrl: "Copy {{scriptName}} URL",
chooseUrl: "Choose URL for {{scriptName}}",
script: "{{scriptName}} script", script: "{{scriptName}} script",
}, },
routes: {
public: "Reverse proxy",
paseo: "Memorable",
direct: "Direct",
},
states: { states: {
exitCode: "exit {{code}}", exitCode: "exit {{code}}",
startFailed: "Failed to start {{scriptName}}", startFailed: "Failed to start {{scriptName}}",
stopFailed: "Failed to stop {{scriptName}}",
}, },
}, },
git: { git: {
+17 -2
View File
@@ -594,19 +594,34 @@ export const es: TranslationResources = {
scripts: { scripts: {
title: "Scripts", title: "Scripts",
actions: { actions: {
chooseUrl: "Elegir URL",
copyUrl: "Copiar URL",
openService: "Ver servicio",
restart: "Reiniciar",
run: "Correr", run: "Correr",
view: "Vista", stop: "Detener",
view: "Ver terminal",
}, },
accessibility: { accessibility: {
trigger: "GuionesWorkspace", trigger: "GuionesWorkspace",
openAt: "Abrir{{scriptName}}en{{label}}", openService: "Ver servicio {{scriptName}}",
viewTerminal: "Ver terminal{{scriptName}}", viewTerminal: "Ver terminal{{scriptName}}",
runScript: "Ejecute el script{{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}}", script: "Guión{{scriptName}}",
}, },
routes: {
public: "Proxy inverso",
paseo: "Memorable",
direct: "Directa",
},
states: { states: {
exitCode: "salir de{{code}}", exitCode: "salir de{{code}}",
startFailed: "No se pudo iniciar{{scriptName}}", startFailed: "No se pudo iniciar{{scriptName}}",
stopFailed: "No se pudo detener{{scriptName}}",
}, },
}, },
git: { git: {
+17 -2
View File
@@ -594,19 +594,34 @@ export const fr: TranslationResources = {
scripts: { scripts: {
title: "Scripts", title: "Scripts",
actions: { actions: {
chooseUrl: "Choisir l’URL",
copyUrl: "Copier l’URL",
openService: "Voir le service",
restart: "Redémarrer",
run: "Courir", run: "Courir",
view: "Voir", stop: "Arrêter",
view: "Voir le terminal",
}, },
accessibility: { accessibility: {
trigger: "ScriptsWorkspace", trigger: "ScriptsWorkspace",
openAt: "Ouvrir{{scriptName}}à{{label}}", openService: "Voir le service {{scriptName}}",
viewTerminal: "Voir le terminal{{scriptName}}", viewTerminal: "Voir le terminal{{scriptName}}",
runScript: "Exécuter le script{{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}}", script: "Script{{scriptName}}",
}, },
routes: {
public: "Proxy inverse",
paseo: "Memorable",
direct: "Directe",
},
states: { states: {
exitCode: "quitter{{code}}", exitCode: "quitter{{code}}",
startFailed: "Échec du démarrage de{{scriptName}}", startFailed: "Échec du démarrage de{{scriptName}}",
stopFailed: "Échec de l'arrêt de{{scriptName}}",
}, },
}, },
git: { git: {
+17 -2
View File
@@ -590,19 +590,34 @@ export const ja: TranslationResources = {
scripts: { scripts: {
title: "スクリプト", title: "スクリプト",
actions: { actions: {
chooseUrl: "URLを選択",
copyUrl: "URLをコピー",
openService: "サービスを表示",
restart: "再起動",
run: "実行", run: "実行",
view: "表示", stop: "停止",
view: "ターミナルを表示",
}, },
accessibility: { accessibility: {
trigger: "ワークスペーススクリプト", trigger: "ワークスペーススクリプト",
openAt: "{{label}}で{{scriptName}}を開く", openService: "{{scriptName}}サービスを表示",
viewTerminal: "{{scriptName}}ターミナルを表示", viewTerminal: "{{scriptName}}ターミナルを表示",
runScript: "{{scriptName}}スクリプトを実行", runScript: "{{scriptName}}スクリプトを実行",
stopScript: "{{scriptName}}を停止",
restartScript: "{{scriptName}}を再起動",
copyUrl: "{{scriptName}}のURLをコピー",
chooseUrl: "{{scriptName}}のURLを選択",
script: "{{scriptName}}スクリプト", script: "{{scriptName}}スクリプト",
}, },
routes: {
public: "リバースプロキシ",
paseo: "Memorable",
direct: "直接接続",
},
states: { states: {
exitCode: "終了コード: {{code}}", exitCode: "終了コード: {{code}}",
startFailed: "{{scriptName}}の起動に失敗しました", startFailed: "{{scriptName}}の起動に失敗しました",
stopFailed: "{{scriptName}}の停止に失敗しました",
}, },
}, },
git: { git: {
+17 -2
View File
@@ -591,19 +591,34 @@ export const ptBR: TranslationResources = {
scripts: { scripts: {
title: "Scripts", title: "Scripts",
actions: { actions: {
chooseUrl: "Escolher URL",
copyUrl: "Copiar URL",
openService: "Ver serviço",
restart: "Reiniciar",
run: "Executar", run: "Executar",
view: "Ver", stop: "Parar",
view: "Ver terminal",
}, },
accessibility: { accessibility: {
trigger: "Scripts do workspace", trigger: "Scripts do workspace",
openAt: "Abrir {{scriptName}} em {{label}}", openService: "Ver serviço {{scriptName}}",
viewTerminal: "Ver terminal de {{scriptName}}", viewTerminal: "Ver terminal de {{scriptName}}",
runScript: "Executar script {{scriptName}}", runScript: "Executar script {{scriptName}}",
stopScript: "Parar {{scriptName}}",
restartScript: "Reiniciar {{scriptName}}",
copyUrl: "Copiar URL de {{scriptName}}",
chooseUrl: "Escolher URL para {{scriptName}}",
script: "script {{scriptName}}", script: "script {{scriptName}}",
}, },
routes: {
public: "Proxy reverso",
paseo: "Memorable",
direct: "Direta",
},
states: { states: {
exitCode: "saída {{code}}", exitCode: "saída {{code}}",
startFailed: "Falha ao iniciar {{scriptName}}", startFailed: "Falha ao iniciar {{scriptName}}",
stopFailed: "Falha ao parar {{scriptName}}",
}, },
}, },
git: { git: {
+17 -2
View File
@@ -593,19 +593,34 @@ export const ru: TranslationResources = {
scripts: { scripts: {
title: "Скрипты", title: "Скрипты",
actions: { actions: {
chooseUrl: "Выбрать URL",
copyUrl: "Скопировать URL",
openService: "Просмотреть сервис",
restart: "Перезапустить",
run: "Бегать", run: "Бегать",
view: "Вид", stop: "Остановить",
view: "Открыть терминал",
}, },
accessibility: { accessibility: {
trigger: "Скрипты Workspace", trigger: "Скрипты Workspace",
openAt: "Откройте{{scriptName}}на{{label}}", openService: "Просмотреть сервис {{scriptName}}",
viewTerminal: "Посмотреть терминал{{scriptName}}", viewTerminal: "Посмотреть терминал{{scriptName}}",
runScript: "Запустите скрипт{{scriptName}}", runScript: "Запустите скрипт{{scriptName}}",
stopScript: "Остановить{{scriptName}}",
restartScript: "Перезапустить{{scriptName}}",
copyUrl: "Скопировать URL {{scriptName}}",
chooseUrl: "Выбрать URL для {{scriptName}}",
script: "скрипт{{scriptName}}", script: "скрипт{{scriptName}}",
}, },
routes: {
public: "Обратный прокси",
paseo: "Memorable",
direct: "Прямой адрес",
},
states: { states: {
exitCode: "выйти из{{code}}", exitCode: "выйти из{{code}}",
startFailed: "Не удалось запустить{{scriptName}}", startFailed: "Не удалось запустить{{scriptName}}",
stopFailed: "Не удалось остановить{{scriptName}}",
}, },
}, },
git: { git: {
+17 -2
View File
@@ -584,19 +584,34 @@ export const zhCN: TranslationResources = {
scripts: { scripts: {
title: "Scripts", title: "Scripts",
actions: { actions: {
chooseUrl: "选择 URL",
copyUrl: "复制 URL",
openService: "查看服务",
restart: "重启",
run: "运行", run: "运行",
view: "查看", stop: "停止",
view: "查看终端",
}, },
accessibility: { accessibility: {
trigger: "Workspace scripts", trigger: "Workspace scripts",
openAt: "在 {{label}} 打开 {{scriptName}}", openService: "查看 {{scriptName}} 服务",
viewTerminal: "查看 {{scriptName}} Terminal", viewTerminal: "查看 {{scriptName}} Terminal",
runScript: "运行 {{scriptName}} script", runScript: "运行 {{scriptName}} script",
stopScript: "停止 {{scriptName}}",
restartScript: "重启 {{scriptName}}",
copyUrl: "复制 {{scriptName}} 的 URL",
chooseUrl: "选择 {{scriptName}} 的 URL",
script: "{{scriptName}} script", script: "{{scriptName}} script",
}, },
routes: {
public: "反向代理",
paseo: "Memorable",
direct: "直接地址",
},
states: { states: {
exitCode: "exit {{code}}", exitCode: "exit {{code}}",
startFailed: "启动 {{scriptName}} 失败", startFailed: "启动 {{scriptName}} 失败",
stopFailed: "停止 {{scriptName}} 失败",
}, },
}, },
git: { git: {
+2
View File
@@ -5,6 +5,7 @@
* Z-index scale within overlay root: * Z-index scale within overlay root:
* - Modal backdrop/content: 10 * - Modal backdrop/content: 10
* - Toast: 20 * - Toast: 20
* - Tooltip: 30
*/ */
export function getOverlayRoot(): HTMLElement { export function getOverlayRoot(): HTMLElement {
let el = document.getElementById("overlay-root"); let el = document.getElementById("overlay-root");
@@ -22,4 +23,5 @@ export function getOverlayRoot(): HTMLElement {
export const OVERLAY_Z = { export const OVERLAY_Z = {
modal: 10, modal: 10,
toast: 20, toast: 20,
tooltip: 30,
} as const; } as const;
@@ -3,7 +3,7 @@
*/ */
import { i18n as testI18n } from "@/i18n/i18next"; import { i18n as testI18n } from "@/i18n/i18next";
import React, { type ReactElement } from "react"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -12,7 +12,16 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-bu
void testI18n; void testI18n;
const { theme, startWorkspaceScriptMock } = vi.hoisted(() => { const {
theme,
startWorkspaceScriptMock,
killTerminalMock,
setStringAsyncMock,
copiedToastMock,
routePreferenceByServerIdMock,
routePreferenceListenersMock,
setPreferredRouteMock,
} = vi.hoisted(() => {
const hoistedTheme = { const hoistedTheme = {
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 }, spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 },
borderWidth: { 1: 1 }, borderWidth: { 1: 1 },
@@ -32,9 +41,26 @@ const { theme, startWorkspaceScriptMock } = vi.hoisted(() => {
}, },
}; };
const routePreferenceByServerId: Record<string, "public" | "paseo" | "direct"> = {};
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 { return {
theme: hoistedTheme, theme: hoistedTheme,
startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })), 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 }), useHostRuntimeSnapshot: () => ({ activeConnection: null }),
})); }));
vi.mock("@/workspace-service-routes/store", async () => {
const ReactModule = await vi.importActual<typeof import("react")>("react");
const state = {
byServerId: routePreferenceByServerIdMock,
setPreferredRoute: setPreferredRouteMock,
};
return {
useWorkspaceServiceRoutePreferencesStore: <T,>(selector: (value: typeof state) => T) =>
ReactModule.useSyncExternalStore(
(listener) => {
routePreferenceListenersMock.add(listener);
return () => routePreferenceListenersMock.delete(listener);
},
() => selector(state),
() => selector(state),
),
};
});
vi.mock("@/stores/session-store", () => ({ vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) => useSessionStore: (selector: (state: unknown) => unknown) =>
selector({ selector({
@@ -75,6 +120,7 @@ vi.mock("@/stores/session-store", () => ({
"test-server": { "test-server": {
client: { client: {
startWorkspaceScript: startWorkspaceScriptMock, startWorkspaceScript: startWorkspaceScriptMock,
killTerminal: killTerminalMock,
}, },
}, },
}, },
@@ -82,7 +128,11 @@ vi.mock("@/stores/session-store", () => ({
})); }));
vi.mock("@/contexts/toast-context", () => ({ 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", () => ({ vi.mock("@/utils/open-external-url", () => ({
@@ -95,6 +145,22 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
<div data-testid={testID}>{children}</div> <div data-testid={testID}>{children}</div>
), ),
DropdownMenuSeparator: () => <div role="separator" />, DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuItem: ({
children,
description,
onSelect,
testID,
}: {
children: React.ReactNode;
description?: string;
onSelect?: () => void;
testID?: string;
}) => (
<button type="button" data-testid={testID} onClick={onSelect}>
{children}
{description}
</button>
),
DropdownMenuTrigger: ({ DropdownMenuTrigger: ({
children, children,
testID, testID,
@@ -113,6 +179,14 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
useDropdownMenuClose: () => () => {}, 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 }) => (
<div data-testid={testID}>{children}</div>
),
}));
vi.mock("lucide-react-native", () => { vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) => const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { React.createElement("span", {
@@ -123,9 +197,13 @@ vi.mock("lucide-react-native", () => {
}); });
return { return {
ChevronDown: createIcon("ChevronDown"), ChevronDown: createIcon("ChevronDown"),
Copy: createIcon("Copy"),
Eye: createIcon("Eye"),
ExternalLink: createIcon("ExternalLink"), ExternalLink: createIcon("ExternalLink"),
Globe: createIcon("Globe"), Globe: createIcon("Globe"),
Play: createIcon("Play"), Play: createIcon("Play"),
RotateCw: createIcon("RotateCw"),
Square: createIcon("Square"),
SquareTerminal: createIcon("SquareTerminal"), SquareTerminal: createIcon("SquareTerminal"),
}; };
}); });
@@ -143,6 +221,8 @@ function script(
type: input.type ?? "script", type: input.type ?? "script",
hostname: input.hostname ?? input.scriptName, hostname: input.hostname ?? input.scriptName,
port: input.port ?? null, port: input.port ?? null,
localProxyUrl: input.localProxyUrl,
publicProxyUrl: input.publicProxyUrl,
proxyUrl: input.proxyUrl ?? null, proxyUrl: input.proxyUrl ?? null,
lifecycle: input.lifecycle ?? "stopped", lifecycle: input.lifecycle ?? "stopped",
health: input.health ?? null, health: input.health ?? null,
@@ -241,6 +321,13 @@ describe("WorkspaceScriptsButton", () => {
); );
document.body.innerHTML = ""; document.body.innerHTML = "";
startWorkspaceScriptMock.mockClear(); startWorkspaceScriptMock.mockClear();
killTerminalMock.mockClear();
setStringAsyncMock.mockClear();
copiedToastMock.mockClear();
setPreferredRouteMock.mockClear();
for (const serverId of Object.keys(routePreferenceByServerIdMock)) {
delete routePreferenceByServerIdMock[serverId];
}
}); });
afterEach(() => { afterEach(() => {
@@ -278,7 +365,7 @@ describe("WorkspaceScriptsButton", () => {
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted); expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
expect(row.textContent).toContain("typecheck"); expect(row.textContent).toContain("typecheck");
expect(row.textContent).toContain("exit 0"); 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([ await current.rerender([
script({ script({
@@ -294,7 +381,7 @@ describe("WorkspaceScriptsButton", () => {
expect(icon.dataset.icon).toBe("SquareTerminal"); expect(icon.dataset.icon).toBe("SquareTerminal");
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted); expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
expect(row.textContent).toContain("exit 7"); 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", () => { 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"]'); const trigger = document.querySelector('[data-testid="workspace-scripts-button"]');
expect(trigger?.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); 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");
});
}); });
@@ -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 type { GestureResponderEvent } from "react-native";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useMutation } from "@tanstack/react-query"; 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 { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -11,17 +21,23 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
useDropdownMenuClose, useDropdownMenuClose,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { isNative } from "@/constants/platform";
import { openServiceUrl } from "@/utils/open-service-url"; 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 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 { interface WorkspaceScriptsButtonProps {
serverId: string; serverId: string;
@@ -35,20 +51,14 @@ interface WorkspaceScriptsButtonProps {
presentation?: "split" | "ghost"; presentation?: "split" | "ghost";
} }
interface ScriptActionButtonProps {
accessibilityLabel: string;
disabled?: boolean;
icon: ScriptActionIcon;
label: string;
onPress: () => void;
testID: string;
}
const ThemedPlay = withUnistyles(Play); const ThemedPlay = withUnistyles(Play);
const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
const ThemedChevronDown = withUnistyles(ChevronDown); 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; const GHOST_TRIGGER_ICON_SIZE = 16;
@@ -70,43 +80,47 @@ const redColorMapping = (theme: Theme) => ({
const playFillTransparent = { fill: "transparent" }; const playFillTransparent = { fill: "transparent" };
const ghostPlayStroke = { strokeWidth: 1.5 }; const ghostPlayStroke = { strokeWidth: 1.5 };
interface ScriptActionButtonChildrenProps { interface ScriptRowActionButtonProps {
hovered?: boolean; accessibilityLabel: string;
icon: ScriptActionIcon; disabled?: boolean;
label: string; icon: RowActionIcon;
onPress: () => void;
testID: string;
tooltipLabel: string;
} }
function ScriptActionButtonChildren({ function RowActionIconElement({
hovered, hovered,
icon, icon,
label, }: {
}: ScriptActionButtonChildrenProps): ReactElement { hovered?: boolean;
icon: RowActionIcon;
}): ReactElement {
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping; const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
const iconElement = switch (icon) {
icon === "view" ? ( case "copy":
<ThemedSquareTerminal size={10} uniProps={colorMapping} /> return <ThemedCopy size={11} uniProps={colorMapping} />;
) : ( case "open":
<ThemedPlay size={10} uniProps={colorMapping} {...playFillTransparent} /> return <ThemedEye size={12} uniProps={colorMapping} />;
); case "restart":
const labelStyle = hovered return <ThemedRotateCw size={11} uniProps={colorMapping} />;
? [styles.actionButtonLabel, styles.actionButtonLabelHovered] case "start":
: styles.actionButtonLabel; return <ThemedPlay size={11} uniProps={colorMapping} {...playFillTransparent} />;
return ( case "stop":
<> return <ThemedSquare size={11} uniProps={colorMapping} />;
{iconElement} case "terminal":
<Text style={labelStyle}>{label}</Text> return <ThemedSquareTerminal size={12} uniProps={colorMapping} />;
</> }
);
} }
function ScriptActionButton({ function ScriptRowActionButton({
accessibilityLabel, accessibilityLabel,
disabled, disabled,
icon, icon,
label,
onPress, onPress,
testID, testID,
}: ScriptActionButtonProps): ReactElement { tooltipLabel,
}: ScriptRowActionButtonProps): ReactElement {
const handlePress = useCallback( const handlePress = useCallback(
(event: GestureResponderEvent) => { (event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -116,94 +130,204 @@ function ScriptActionButton({
); );
const renderChildren = useCallback( const renderChildren = useCallback(
({ hovered }: { hovered?: boolean }) => ( ({ hovered }: { hovered?: boolean }) => <RowActionIconElement hovered={hovered} icon={icon} />,
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} /> [icon],
),
[icon, label],
); );
return ( return (
<Pressable <Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
accessibilityRole="button" <TooltipTrigger asChild triggerRefProp="ref">
accessibilityLabel={accessibilityLabel} <Pressable
testID={testID} accessibilityRole="button"
hitSlop={4} accessibilityLabel={accessibilityLabel}
disabled={disabled} testID={testID}
onPress={handlePress} hitSlop={6}
style={styles.actionButton} disabled={disabled}
> onPress={handlePress}
{renderChildren} style={styles.iconActionButton}
</Pressable> >
{renderChildren}
</Pressable>
</TooltipTrigger>
<TooltipContent testID={`${testID}-tooltip`} side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
); );
} }
function stripUrlProtocol(url: string): string { interface ServiceLinkRowProps {
return url.replace(/^https?:\/\//, ""); selectedTarget: WorkspaceScriptLinkTarget;
} targets: WorkspaceScriptLinkTarget[];
interface HostLinkProps {
label: string;
url: string | null;
scriptName: string; scriptName: string;
onOpenInBrowserTab?: (url: string) => void; onSelectKind: (kind: WorkspaceScriptLinkKind) => void;
onCopy: (url: string, label: string) => void;
} }
interface HostLinkChildrenProps { function routeLabelKey(
hovered?: boolean; kind: WorkspaceScriptLinkKind,
disabled: boolean; ):
| "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 (
<DropdownMenuItem
testID={`workspace-scripts-route-${scriptName}-${target.kind}`}
selected={target.kind === selectedKind}
showSelectedCheck
description={target.label}
onSelect={handleSelect}
>
{t(routeLabelKey(target.kind))}
</DropdownMenuItem>
);
}
function ServiceRouteTriggerContent({
hovered,
label,
}: {
hovered: boolean;
label: string; label: string;
} }): ReactElement {
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;
return ( return (
<> <>
<Text style={hostLabelStyle} numberOfLines={1}> <View style={styles.routeSelectorButton}>
<ThemedChevronDown
size={14}
uniProps={hovered ? foregroundColorMapping : mutedColorMapping}
/>
</View>
<Text
style={hovered ? [styles.hostLabel, styles.hostLabelActive] : styles.hostLabel}
numberOfLines={1}
>
{label} {label}
</Text> </Text>
<View style={styles.hostIconSlot}>
{showIcon ? <ThemedExternalLink size={10} uniProps={colorMapping} /> : null}
</View>
</> </>
); );
} }
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 { t } = useTranslation();
const disabled = !url; const accessibilityLabel = t("workspace.scripts.accessibility.chooseUrl", { scriptName });
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 }) => (
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} />
),
[disabled, label],
);
return ( return (
<Pressable <DropdownMenu>
accessibilityRole="link" <Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
accessibilityLabel={t("workspace.scripts.accessibility.openAt", { scriptName, label })} <TooltipTrigger asChild triggerRefProp="ref">
disabled={disabled} <View collapsable={false} style={styles.routeSelectorFrame}>
hitSlop={2} <DropdownMenuTrigger
onPress={handlePress} accessibilityRole="button"
style={styles.hostRow} accessibilityLabel={accessibilityLabel}
> testID={`workspace-scripts-route-${scriptName}`}
{renderChildren} hitSlop={6}
</Pressable> style={styles.routeSelectorTrigger}
>
{({ hovered }) => (
<ServiceRouteTriggerContent hovered={hovered} label={selectedTarget.label} />
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent
testID={`workspace-scripts-route-${scriptName}-tooltip`}
side="top"
align="center"
offset={8}
>
<Text style={styles.tooltipText}>{t("workspace.scripts.actions.chooseUrl")}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="bottom" align="end" minWidth={220} maxWidth={280}>
{targets.map((target) => (
<ServiceRouteOption
key={target.kind}
scriptName={scriptName}
selectedKind={selectedTarget.kind}
target={target}
onSelect={onSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<View style={styles.hostRow}>
{targets.length > 1 ? (
<ServiceRouteSelector
scriptName={scriptName}
selectedTarget={selectedTarget}
targets={targets}
onSelect={onSelectKind}
/>
) : (
<View style={styles.routeDisplay}>
<View style={styles.routeSelectorButton} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
</View>
)}
<ScriptRowActionButton
accessibilityLabel={t("workspace.scripts.accessibility.copyUrl", { scriptName })}
testID={`workspace-scripts-copy-${scriptName}`}
icon="copy"
onPress={handleCopy}
tooltipLabel={t("workspace.scripts.actions.copyUrl")}
/>
</View>
); );
} }
@@ -218,12 +342,6 @@ function ExitCodeBadge({ code }: { code: number }): ReactElement {
); );
} }
interface HostLink {
key: string;
label: string;
url: string | null;
}
interface ScriptRowProps { interface ScriptRowProps {
script: WorkspaceDescriptor["scripts"][number]; script: WorkspaceDescriptor["scripts"][number];
liveTerminalIdSet: Set<string>; liveTerminalIdSet: Set<string>;
@@ -233,7 +351,13 @@ interface ScriptRowProps {
: null : null
: null; : null;
isStartPending: boolean; isStartPending: boolean;
isStopPending: boolean;
onStartScript: (scriptName: string) => void; 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; onViewTerminal?: (terminalId: string) => void;
onOpenUrlInBrowserTab?: (url: string) => void; onOpenUrlInBrowserTab?: (url: string) => void;
} }
@@ -259,7 +383,13 @@ function ScriptRow({
liveTerminalIdSet, liveTerminalIdSet,
activeConnection, activeConnection,
isStartPending, isStartPending,
isStopPending,
onStartScript, onStartScript,
onStopScript,
onRestartScript,
onCopyUrl,
preferredRouteKind,
onSelectRouteKind,
onViewTerminal, onViewTerminal,
onOpenUrlInBrowserTab, onOpenUrlInBrowserTab,
}: ScriptRowProps): ReactElement { }: ScriptRowProps): ReactElement {
@@ -268,36 +398,24 @@ function ScriptRow({
const isService = (script.type ?? "service") === "service"; const isService = (script.type ?? "service") === "service";
const exitCode = script.exitCode ?? null; const exitCode = script.exitCode ?? null;
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); 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 = const liveTerminalId =
script.terminalId && liveTerminalIdSet.has(script.terminalId) ? script.terminalId : null; 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 iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning });
const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal; const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal;
const showExitBadge = !isRunning && exitCode !== null; 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(() => { const handleView = useCallback(() => {
if (liveTerminalId) onViewTerminal?.(liveTerminalId); if (liveTerminalId) onViewTerminal?.(liveTerminalId);
@@ -307,38 +425,67 @@ function ScriptRow({
onStartScript(script.scriptName); onStartScript(script.scriptName);
}, [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( const scriptNameStyle = useMemo(
() => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName), () => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName),
[isRunning], [isRunning],
); );
let primaryAction: ReactElement | null = null; const viewAction =
if (isRunning && liveTerminalId) { isRunning && liveTerminalId ? (
primaryAction = ( <ScriptRowActionButton
<ScriptActionButton
accessibilityLabel={t("workspace.scripts.accessibility.viewTerminal", { accessibilityLabel={t("workspace.scripts.accessibility.viewTerminal", {
scriptName: script.scriptName, scriptName: script.scriptName,
})} })}
testID={`workspace-scripts-view-${script.scriptName}`} testID={`workspace-scripts-view-${script.scriptName}`}
icon="view" icon="terminal"
label={t("workspace.scripts.actions.view")}
onPress={handleView} onPress={handleView}
tooltipLabel={t("workspace.scripts.actions.view")}
/> />
); ) : null;
} else if (!isRunning) {
primaryAction = ( const openServiceAction = selectedLink ? (
<ScriptActionButton <ScriptRowActionButton
accessibilityLabel={t("workspace.scripts.accessibility.runScript", { accessibilityLabel={t("workspace.scripts.accessibility.openService", {
scriptName: script.scriptName, scriptName: script.scriptName,
})} })}
testID={`workspace-scripts-start-${script.scriptName}`} testID={`workspace-scripts-open-${script.scriptName}`}
disabled={isStartPending} icon="open"
icon="start" onPress={handleOpenService}
label={t("workspace.scripts.actions.run")} tooltipLabel={t("workspace.scripts.actions.openService")}
onPress={handleRun} />
/> ) : null;
);
} const lifecycleAction = isRunning ? (
<ScriptRowActionButton
accessibilityLabel={t("workspace.scripts.accessibility.stopScript", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-stop-${script.scriptName}`}
disabled={isStopPending}
icon="stop"
onPress={handleStop}
tooltipLabel={t("workspace.scripts.actions.stop")}
/>
) : (
<ScriptRowActionButton
accessibilityLabel={t("workspace.scripts.accessibility.runScript", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-start-${script.scriptName}`}
disabled={isStartPending}
icon="start"
onPress={handleRun}
tooltipLabel={t("workspace.scripts.actions.run")}
/>
);
return ( return (
<View <View
@@ -355,19 +502,31 @@ function ScriptRow({
</Text> </Text>
{showExitBadge ? <ExitCodeBadge code={exitCode} /> : null} {showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
<View style={styles.spacer} /> <View style={styles.spacer} />
{primaryAction} {openServiceAction}
{viewAction}
{isRunning ? (
<ScriptRowActionButton
accessibilityLabel={t("workspace.scripts.accessibility.restartScript", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-restart-${script.scriptName}`}
disabled={isStopPending}
icon="restart"
onPress={handleRestart}
tooltipLabel={t("workspace.scripts.actions.restart")}
/>
) : null}
{lifecycleAction}
</View> </View>
{hostLinks.length > 0 ? ( {selectedLink ? (
<View style={styles.hostList}> <View style={styles.hostList}>
{hostLinks.map((link) => ( <ServiceLinkRow
<HostLinkRow selectedTarget={selectedLink}
key={link.key} targets={serviceLink.targets}
label={link.label} scriptName={script.scriptName}
url={link.url} onSelectKind={onSelectRouteKind}
scriptName={script.scriptName} onCopy={onCopyUrl}
onOpenInBrowserTab={onOpenUrlInBrowserTab} />
/>
))}
</View> </View>
) : null} ) : null}
</View> </View>
@@ -389,7 +548,14 @@ export function WorkspaceScriptsButton({
const toast = useToast(); const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? 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 liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]);
const pendingRestartRef = useRef<Set<string>>(new Set());
const startScriptMutation = useMutation({ const startScriptMutation = useMutation({
mutationFn: async (scriptName: string) => { 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( const triggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [ ({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
@@ -433,6 +639,32 @@ export function WorkspaceScriptsButton({
[startScriptMutation], [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) { if (scripts.length === 0) {
return null; return null;
} }
@@ -482,7 +714,13 @@ export function WorkspaceScriptsButton({
liveTerminalIdSet={liveTerminalIdSet} liveTerminalIdSet={liveTerminalIdSet}
activeConnection={activeConnection} activeConnection={activeConnection}
isStartPending={startScriptMutation.isPending} isStartPending={startScriptMutation.isPending}
isStopPending={stopScriptMutation.isPending}
onStartScript={handleStartScript} onStartScript={handleStartScript}
onStopScript={handleStopScript}
onRestartScript={handleRestartScript}
onCopyUrl={handleCopyUrl}
preferredRouteKind={preferredRouteKind}
onSelectRouteKind={handleSelectRouteKind}
onViewTerminal={onViewTerminal} onViewTerminal={onViewTerminal}
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab} onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
/> />
@@ -585,10 +823,18 @@ const styles = StyleSheet.create((theme) => ({
hostRow: { hostRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1.5], gap: theme.spacing[2],
paddingVertical: 2, paddingVertical: 2,
minHeight: 18, minHeight: 18,
}, },
routeDisplay: {
flex: 1,
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
hostLabel: { hostLabel: {
flexShrink: 1, flexShrink: 1,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -598,13 +844,6 @@ const styles = StyleSheet.create((theme) => ({
hostLabelActive: { hostLabelActive: {
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
hostIconSlot: {
width: 10,
height: 10,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
exitBadge: { exitBadge: {
paddingHorizontal: theme.spacing[1.5], paddingHorizontal: theme.spacing[1.5],
paddingVertical: 1, paddingVertical: 1,
@@ -620,17 +859,28 @@ const styles = StyleSheet.create((theme) => ({
exitBadgeTextError: { exitBadgeTextError: {
color: theme.colors.palette.red[300], 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", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 3, gap: theme.spacing[2],
minWidth: 0,
}, },
actionButtonLabel: { tooltipText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, color: theme.colors.popoverForeground,
color: theme.colors.foregroundMuted,
},
actionButtonLabelHovered: {
color: theme.colors.foreground,
}, },
})); }));
@@ -17,33 +17,64 @@ const runningService: WorkspaceScriptPayload = {
terminalId: null, terminalId: null,
}; };
function resolveLink(activeConnection: ActiveConnection | null) { function resolveLink(
return resolveWorkspaceScriptLink({ activeConnection: ActiveConnection | null,
script: runningService, script: WorkspaceScriptPayload = runningService,
activeConnection, ) {
}); return resolveWorkspaceScriptLink({ script, activeConnection });
} }
describe("resolveWorkspaceScriptLink", () => { 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( expect(
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }), resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
).toEqual({ ).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767", primary: {
labelUrl: "http://web--feature--paseo.localhost:6767", 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( 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({ ).toEqual({
openUrl: "http://web--feature--paseo.localhost:6767", primary: {
labelUrl: "http://web--feature--paseo.localhost:6767", 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( expect(
resolveLink({ resolveLink({
type: "directTcp", type: "directTcp",
@@ -51,124 +82,123 @@ describe("resolveWorkspaceScriptLink", () => {
display: "mac-mini.tail123.ts.net:6767", display: "mac-mini.tail123.ts.net:6767",
}), }),
).toEqual({ ).toEqual({
openUrl: "http://mac-mini.tail123.ts.net:3000", primary: {
labelUrl: "http://mac-mini.tail123.ts.net:3000", kind: "paseo",
}); label: "web--feature--paseo.localhost:6767",
}); url: "http://web--feature--paseo.localhost:6767",
},
it("shows the local proxy URL but disables opening over relay", () => { targets: [
expect( {
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }), kind: "paseo",
).toEqual({ label: "web--feature--paseo.localhost:6767",
openUrl: null, url: "http://web--feature--paseo.localhost:6767",
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",
}, },
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }, {
}), kind: "direct",
).toEqual({ label: "mac-mini.tail123.ts.net:3000",
openUrl: "https://web--feature--paseo.services.example.com", url: "http://mac-mini.tail123.ts.net:3000",
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",
}, },
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( expect(
resolveWorkspaceScriptLink({ resolveLink(
script: { { type: "directTcp", endpoint: "mac-mini.tail123.ts.net:6767", display: "remote" },
...runningService, { ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl },
publicProxyUrl: "https://web--feature--paseo.services.example.com", ).targets,
proxyUrl: "https://web--feature--paseo.services.example.com", ).toEqual([
}, { kind: "public", label: "web--feature--paseo.services.example.com", url: publicUrl },
activeConnection: { type: "directPipe", endpoint: "paseo", display: "pipe" }, {
}), kind: "paseo",
).toEqual({ label: "web--feature--paseo.localhost:6767",
openUrl: "http://web--feature--paseo.localhost:6767", url: "http://web--feature--paseo.localhost:6767",
labelUrl: "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", () => { it("keeps service routes available independently of a relay connection", () => {
expect( const relay: ActiveConnection = {
resolveWorkspaceScriptLink({ type: "relay",
script: { endpoint: "relay.paseo.sh:443",
...runningService, display: "relay",
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",
}; };
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( expect(
resolveWorkspaceScriptLink({ resolveLink(relay, { ...runningService, publicProxyUrl: publicUrl, proxyUrl: publicUrl }),
script: oldPayload,
activeConnection: { type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" },
}),
).toEqual({ ).toEqual({
openUrl: "https://web--feature--paseo.services.example.com", primary: {
labelUrl: "https://web--feature--paseo.services.example.com", 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: [],
}); });
}); });
}); });
@@ -2,9 +2,17 @@ import { parseHostPort } from "@getpaseo/protocol/daemon-endpoints";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import type { ActiveConnection } from "@/runtime/host-runtime"; 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 { export interface ResolvedWorkspaceScriptLink {
openUrl: string | null; primary: WorkspaceScriptLinkTarget | null;
labelUrl: string | null; targets: WorkspaceScriptLinkTarget[];
} }
function isLoopbackHost(host: string): boolean { function isLoopbackHost(host: string): boolean {
@@ -15,77 +23,70 @@ function isLoopbackHost(host: string): boolean {
} }
function isLocalOnlyUrl(url: string | null | undefined): boolean { function isLocalOnlyUrl(url: string | null | undefined): boolean {
if (!url) { if (!url) return true;
return true;
}
try { try {
const parsed = new URL(url); const hostname = new URL(url).hostname.toLowerCase();
const hostname = parsed.hostname.toLowerCase();
return isLoopbackHost(hostname) || hostname.endsWith(".localhost"); return isLoopbackHost(hostname) || hostname.endsWith(".localhost");
} catch { } catch {
return true; 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 { try {
const { host, isIpv6 } = parseHostPort(endpoint); const { host, isIpv6 } = parseHostPort(activeConnection.endpoint);
const base = isIpv6 ? `[${host}]` : host; let base = host;
if (isLoopbackHost(host)) {
base = "localhost";
} else if (isIpv6) {
base = `[${host}]`;
}
return `http://${base}:${port}`; return `http://${base}:${port}`;
} catch { } 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: { export function resolveWorkspaceScriptLink(input: {
script: WorkspaceScriptPayload; script: WorkspaceScriptPayload;
activeConnection: ActiveConnection | null; activeConnection: ActiveConnection | null;
}): ResolvedWorkspaceScriptLink { }): ResolvedWorkspaceScriptLink {
const { script, activeConnection } = input; const { script, activeConnection } = input;
if (script.type !== "service" || script.lifecycle !== "running") { if (script.type !== "service" || script.lifecycle !== "running") {
return { openUrl: null, labelUrl: null }; return { primary: null, targets: [] };
} }
if (!activeConnection) { // COMPAT(workspaceScriptSplitUrls): added in v0.2.0, remove after 2027-01-21.
return { openUrl: null, labelUrl: script.proxyUrl }; // Old daemons only send proxyUrl, so classify it by reachability.
} const localProxyUrl =
script.localProxyUrl ?? (isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
const localProxyUrl = script.localProxyUrl ?? script.proxyUrl;
const publicProxyUrl = const publicProxyUrl =
script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null); script.publicProxyUrl ?? (!isLocalOnlyUrl(script.proxyUrl) ? script.proxyUrl : null);
const preferredProxyUrl = publicProxyUrl ?? localProxyUrl ?? script.proxyUrl;
if (activeConnection.type === "relay") { const targets: WorkspaceScriptLinkTarget[] = [];
return { addTarget(targets, "public", publicProxyUrl);
openUrl: publicProxyUrl, addTarget(targets, "paseo", localProxyUrl);
labelUrl: publicProxyUrl ?? localProxyUrl ?? script.proxyUrl, addTarget(targets, "direct", buildDirectServiceUrl(activeConnection, script.port));
};
}
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") { return { primary: targets[0] ?? null, targets };
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,
};
} }
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import { createWorkspaceServiceRoutePreferencesStore } from "./store";
function createMemoryStorage(initial?: Record<string, string>): StateStorage & {
values: Map<string, string>;
} {
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" });
});
});
@@ -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<string, WorkspaceScriptLinkKind>;
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<string, WorkspaceScriptLinkKind> {
if (!value || typeof value !== "object") return {};
const byServerId = (value as { byServerId?: unknown }).byServerId;
if (!byServerId || typeof byServerId !== "object") return {};
const result: Record<string, WorkspaceScriptLinkKind> = {};
for (const [serverId, kind] of Object.entries(byServerId)) {
if (isWorkspaceScriptLinkKind(kind)) result[serverId] = kind;
}
return result;
}
export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) {
return create<WorkspaceServiceRoutePreferencesState>()(
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);
@@ -26,6 +26,7 @@ function makeDescriptor(overrides: {
projectKind?: string; projectKind?: string;
workspaceKind?: "directory" | "local_checkout" | "worktree"; workspaceKind?: "directory" | "local_checkout" | "worktree";
name?: string | null; name?: string | null;
currentBranch?: string | null;
diffStat?: { additions: number; deletions: number } | null; diffStat?: { additions: number; deletions: number } | null;
}): WorkspaceDescriptorPayload { }): WorkspaceDescriptorPayload {
return { return {
@@ -34,6 +35,9 @@ function makeDescriptor(overrides: {
projectKind: overrides.projectKind ?? "git", projectKind: overrides.projectKind ?? "git",
workspaceKind: overrides.workspaceKind ?? "local_checkout", workspaceKind: overrides.workspaceKind ?? "local_checkout",
name: overrides.name ?? null, name: overrides.name ?? null,
...(overrides.currentBranch !== undefined
? { gitRuntime: { currentBranch: overrides.currentBranch } }
: {}),
diffStat: overrides.diffStat ?? null, diffStat: overrides.diffStat ?? null,
} as unknown as WorkspaceDescriptorPayload; } as unknown as WorkspaceDescriptorPayload;
} }
@@ -259,10 +263,31 @@ describe("shouldSkipUpdate", () => {
}); });
describe("recordDescriptorState", () => { 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", () => { test("fires onBranchChanged once per branch name transition", () => {
const h = buildHarness(); const h = buildHarness();
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); 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);
h.service.recordDescriptorState("ws1", feature); h.service.recordDescriptorState("ws1", feature);
expect(h.branchChanges).toEqual([["ws1", null, "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", () => { test("keeps a shared cwd subscription until its last workspace is removed", () => {
const h = buildHarness(); const h = buildHarness();
h.service.syncObservers([ h.service.syncObservers([
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }), makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "Main", currentBranch: "main" }),
makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "main" }), makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "Main", currentBranch: "main" }),
]); ]);
h.emitSnapshot(WS1, "feature"); h.emitSnapshot(WS1, "feature");
@@ -28,8 +28,8 @@ interface WorkspaceGitWatchState {
* watch without sharing identity or teardown lifetime. * watch without sharing identity or teardown lifetime.
* *
* Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the * Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the
* on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop * on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop's Git runtime
* (recordDescriptorState). Both stay inside this module so the shared state is coherent. * projection (recordDescriptorState). Both stay inside this module so the shared state is coherent.
*/ */
export interface WorkspaceGitObserverService { export interface WorkspaceGitObserverService {
syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void; syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void;
@@ -92,7 +92,10 @@ export function createWorkspaceGitObserverService(deps: {
return; return;
} }
state.latestDescriptorStateKey = descriptorStateKey(workspace); 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 { function removeForCwd(cwd: string): void {
@@ -225,8 +228,8 @@ export function createWorkspaceGitObserverService(deps: {
recordDescriptorState(workspaceId, nextWorkspace) { recordDescriptorState(workspaceId, nextWorkspace) {
const state = workspaceStates.get(workspaceId); const state = workspaceStates.get(workspaceId);
if (state && onBranchChanged) { const newBranchName = nextWorkspace?.gitRuntime?.currentBranch;
const newBranchName = nextWorkspace?.name ?? null; if (state && onBranchChanged && newBranchName !== undefined) {
if (newBranchName !== state.lastBranchName) { if (newBranchName !== state.lastBranchName) {
onBranchChanged(workspaceId, state.lastBranchName, newBranchName); onBranchChanged(workspaceId, state.lastBranchName, newBranchName);
} }