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