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:31:42 +02:00
parent afeb4d18f8
commit 8aa55db1e8
20 changed files with 1242 additions and 428 deletions

View File

@@ -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 `<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

View File

@@ -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({
</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 (
<Modal
visible={modalVisible}
@@ -622,27 +663,7 @@ export function DropdownMenuContent({
onDismiss={flushPendingSelect}
onRequestClose={handleClose}
>
<View style={styles.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>
{overlay}
</Modal>
);
}
@@ -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,

View File

@@ -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<Rect | null>(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 (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
return createPortal(
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>,
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],

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -594,19 +594,34 @@ export const fr: TranslationResources = {
scripts: {
title: "Scripts",
actions: {
chooseUrl: "Choisir lURL",
copyUrl: "Copier lURL",
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 lURL 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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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<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 {
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<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", () => ({
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", () => ({
<div data-testid={testID}>{children}</div>
),
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: ({
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 }) => (
<div data-testid={testID}>{children}</div>
),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
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");
});
});

View File

@@ -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" ? (
<ThemedSquareTerminal size={10} uniProps={colorMapping} />
) : (
<ThemedPlay size={10} uniProps={colorMapping} {...playFillTransparent} />
);
const labelStyle = hovered
? [styles.actionButtonLabel, styles.actionButtonLabelHovered]
: styles.actionButtonLabel;
return (
<>
{iconElement}
<Text style={labelStyle}>{label}</Text>
</>
);
switch (icon) {
case "copy":
return <ThemedCopy size={11} uniProps={colorMapping} />;
case "open":
return <ThemedEye size={12} uniProps={colorMapping} />;
case "restart":
return <ThemedRotateCw size={11} uniProps={colorMapping} />;
case "start":
return <ThemedPlay size={11} uniProps={colorMapping} {...playFillTransparent} />;
case "stop":
return <ThemedSquare size={11} uniProps={colorMapping} />;
case "terminal":
return <ThemedSquareTerminal size={12} uniProps={colorMapping} />;
}
}
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 }) => (
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} />
),
[icon, label],
({ hovered }: { hovered?: boolean }) => <RowActionIconElement hovered={hovered} icon={icon} />,
[icon],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
hitSlop={4}
disabled={disabled}
onPress={handlePress}
style={styles.actionButton}
>
{renderChildren}
</Pressable>
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
hitSlop={6}
disabled={disabled}
onPress={handlePress}
style={styles.iconActionButton}
>
{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 {
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 (
<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;
}
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 (
<>
<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}
</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 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 }) => (
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} />
),
[disabled, label],
);
const accessibilityLabel = t("workspace.scripts.accessibility.chooseUrl", { scriptName });
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.scripts.accessibility.openAt", { scriptName, label })}
disabled={disabled}
hitSlop={2}
onPress={handlePress}
style={styles.hostRow}
>
{renderChildren}
</Pressable>
<DropdownMenu>
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<View collapsable={false} style={styles.routeSelectorFrame}>
<DropdownMenuTrigger
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={`workspace-scripts-route-${scriptName}`}
hitSlop={6}
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 {
script: WorkspaceDescriptor["scripts"][number];
liveTerminalIdSet: Set<string>;
@@ -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 = (
<ScriptActionButton
const viewAction =
isRunning && liveTerminalId ? (
<ScriptRowActionButton
accessibilityLabel={t("workspace.scripts.accessibility.viewTerminal", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-view-${script.scriptName}`}
icon="view"
label={t("workspace.scripts.actions.view")}
icon="terminal"
onPress={handleView}
tooltipLabel={t("workspace.scripts.actions.view")}
/>
);
} else if (!isRunning) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={t("workspace.scripts.accessibility.runScript", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-start-${script.scriptName}`}
disabled={isStartPending}
icon="start"
label={t("workspace.scripts.actions.run")}
onPress={handleRun}
/>
);
}
) : null;
const openServiceAction = selectedLink ? (
<ScriptRowActionButton
accessibilityLabel={t("workspace.scripts.accessibility.openService", {
scriptName: script.scriptName,
})}
testID={`workspace-scripts-open-${script.scriptName}`}
icon="open"
onPress={handleOpenService}
tooltipLabel={t("workspace.scripts.actions.openService")}
/>
) : 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 (
<View
@@ -355,19 +502,31 @@ function ScriptRow({
</Text>
{showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
<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>
{hostLinks.length > 0 ? (
{selectedLink ? (
<View style={styles.hostList}>
{hostLinks.map((link) => (
<HostLinkRow
key={link.key}
label={link.label}
url={link.url}
scriptName={script.scriptName}
onOpenInBrowserTab={onOpenUrlInBrowserTab}
/>
))}
<ServiceLinkRow
selectedTarget={selectedLink}
targets={serviceLink.targets}
scriptName={script.scriptName}
onSelectKind={onSelectRouteKind}
onCopy={onCopyUrl}
/>
</View>
) : null}
</View>
@@ -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<Set<string>>(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,
},
}));

View File

@@ -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: [],
});
});
});

View File

@@ -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 };
}

View File

@@ -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" });
});
});

View File

@@ -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);

View File

@@ -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");

View File

@@ -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<WorkspaceDescriptorPayload>): 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);
}