mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Run and watch workspace scripts from the phone header menu (#7122)
* feat(app): run and watch workspace scripts from the compact header menu The compact header redesign dropped the mobile scripts button, so phones had no way to run, stop, or restart workspace scripts. Scripts are now a page of the ⋯ sheet: the root row shows what is running, tinted by health, and the ⋯ carries a dot while a running service is unhealthy. Desktop keeps its popover; both surfaces share one set of script actions and one status model. Script status also froze on native whenever the sidebar was closed and no agent tab was viewed. DirectorySynchronizer releases its subscriptions, including script_status_update, when nothing demands the directory, and the workspace screen never demanded the workspace it shows. Web hid this because the favicon sync holds demand for the whole session. The focused workspace now holds directory demand, which also lets Restart's second half (start once the daemon reports the script stopped) complete. * perf(app): keep workspace script status live without syncing the directory The focused workspace held full directory demand so the scripts menu saw script_status_update on phones with the sidebar closed and no agent tab. That fetched the host's agent and workspace lists on every focus and streamed every agent and workspace update for the whole host. Script status now has its own event subscription, which a workspace screen can hold alone. It fetches the workspace's scripts once when the subscription lands, because the subscription only carries changes. Directory demand also holds it, so the sidebar and agent tabs see the same updates as before.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Workspace scripts: the desktop header popover, and the pages the compact header menu carries.
|
||||
* Both share one set of actions and one reading of script status.
|
||||
*/
|
||||
export { WorkspaceScriptsButton } from "./workspace-scripts-button";
|
||||
export {
|
||||
useWorkspaceScriptsMenuPages,
|
||||
type UseWorkspaceScriptsMenuPagesInput,
|
||||
type WorkspaceScriptsMenuPages,
|
||||
} from "./workspace-scripts-menu-pages";
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Globe, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links";
|
||||
import { scriptStatus, scriptToneColorMapping } from "./script-status";
|
||||
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
|
||||
/** A globe for a service, a terminal for a plain script, in the colour of what it is doing. */
|
||||
export function ScriptIcon({
|
||||
script,
|
||||
size,
|
||||
}: {
|
||||
script: WorkspaceDescriptor["scripts"][number];
|
||||
size: number;
|
||||
}): ReactElement {
|
||||
const Icon = script.type === "service" ? ThemedGlobe : ThemedSquareTerminal;
|
||||
return (
|
||||
<Icon
|
||||
size={size}
|
||||
uniProps={scriptToneColorMapping(scriptStatus(script))}
|
||||
style={styles.scriptIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExitCodeBadge({ code }: { code: number }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const exitTextStyle =
|
||||
code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError];
|
||||
return (
|
||||
<View style={styles.exitBadge}>
|
||||
<Text style={exitTextStyle}>{t("workspace.scripts.states.exitCode", { code })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The label of a URL route, as the user picks between them. */
|
||||
export 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";
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
scriptIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
exitBadge: {
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
exitBadgeText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
exitBadgeTextError: {
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
import { scriptStatus, summarizeScripts } from "./script-status";
|
||||
|
||||
function service(
|
||||
scriptName: string,
|
||||
state: Pick<WorkspaceScriptPayload, "lifecycle" | "health">,
|
||||
): WorkspaceScriptPayload {
|
||||
return { ...script(scriptName, state.lifecycle), type: "service", health: state.health };
|
||||
}
|
||||
|
||||
function script(
|
||||
scriptName: string,
|
||||
lifecycle: WorkspaceScriptPayload["lifecycle"],
|
||||
): WorkspaceScriptPayload {
|
||||
return {
|
||||
scriptName,
|
||||
type: "script",
|
||||
hostname: scriptName,
|
||||
port: null,
|
||||
proxyUrl: null,
|
||||
lifecycle,
|
||||
health: null,
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("scriptStatus", () => {
|
||||
it("colours services by their health and plain scripts by whether they run", () => {
|
||||
expect(scriptStatus(script("lint", "stopped"))).toBe("idle");
|
||||
expect(scriptStatus(script("lint", "running"))).toBe("running");
|
||||
expect(scriptStatus(service("web", { lifecycle: "stopped", health: "unhealthy" }))).toBe(
|
||||
"idle",
|
||||
);
|
||||
expect(scriptStatus(service("web", { lifecycle: "running", health: null }))).toBe("running");
|
||||
expect(scriptStatus(service("web", { lifecycle: "running", health: "healthy" }))).toBe(
|
||||
"healthy",
|
||||
);
|
||||
expect(scriptStatus(service("web", { lifecycle: "running", health: "unhealthy" }))).toBe(
|
||||
"unhealthy",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeScripts", () => {
|
||||
it("is idle with nothing running when nothing runs", () => {
|
||||
expect(summarizeScripts([])).toEqual({ tone: "idle", running: [] });
|
||||
expect(summarizeScripts([script("lint", "stopped")])).toEqual({ tone: "idle", running: [] });
|
||||
});
|
||||
|
||||
it("lists the running scripts by name", () => {
|
||||
expect(
|
||||
summarizeScripts([
|
||||
script("lint", "stopped"),
|
||||
service("web", { lifecycle: "running", health: null }),
|
||||
]),
|
||||
).toEqual({ tone: "running", running: ["web"] });
|
||||
});
|
||||
|
||||
it("is unhealthy when any running service is", () => {
|
||||
expect(
|
||||
summarizeScripts([
|
||||
service("web", { lifecycle: "running", health: "healthy" }),
|
||||
service("api", { lifecycle: "running", health: "unhealthy" }),
|
||||
]),
|
||||
).toEqual({ tone: "unhealthy", running: ["web", "api"] });
|
||||
});
|
||||
|
||||
it("is healthy only when every running service reports healthy", () => {
|
||||
expect(
|
||||
summarizeScripts([
|
||||
script("lint", "running"),
|
||||
service("web", { lifecycle: "running", health: "healthy" }),
|
||||
service("api", { lifecycle: "stopped", health: "unhealthy" }),
|
||||
]),
|
||||
).toEqual({ tone: "healthy", running: ["lint", "web"] });
|
||||
expect(
|
||||
summarizeScripts([
|
||||
service("web", { lifecycle: "running", health: "healthy" }),
|
||||
service("api", { lifecycle: "running", health: null }),
|
||||
]),
|
||||
).toEqual({ tone: "running", running: ["web", "api"] });
|
||||
expect(summarizeScripts([script("lint", "running")])).toEqual({
|
||||
tone: "running",
|
||||
running: ["lint"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
* What a script, or a set of them, is doing, as the one colour its ▶ or row icon is drawn in.
|
||||
*
|
||||
* Health is a service's own report, so it only colours running services. A plain script that is
|
||||
* running is just running.
|
||||
*/
|
||||
export type ScriptTone = "idle" | "running" | "healthy" | "unhealthy";
|
||||
|
||||
export interface ScriptsSummary {
|
||||
tone: ScriptTone;
|
||||
/** Names of the running scripts, in the order the workspace lists them. */
|
||||
running: string[];
|
||||
}
|
||||
|
||||
export function scriptStatus(script: WorkspaceScriptPayload): ScriptTone {
|
||||
if (script.lifecycle !== "running") return "idle";
|
||||
if (script.type !== "service") return "running";
|
||||
if (script.health === "healthy") return "healthy";
|
||||
if (script.health === "unhealthy") return "unhealthy";
|
||||
return "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* One unhealthy service makes the whole set unhealthy. The set reads healthy only when every
|
||||
* running service says so; a service that has not reported yet keeps it at plain running.
|
||||
*/
|
||||
export function summarizeScripts(scripts: readonly WorkspaceScriptPayload[]): ScriptsSummary {
|
||||
const running = scripts.filter((script) => script.lifecycle === "running");
|
||||
if (running.length === 0) return { tone: "idle", running: [] };
|
||||
return { tone: aggregateTone(running), running: running.map((script) => script.scriptName) };
|
||||
}
|
||||
|
||||
function aggregateTone(running: readonly WorkspaceScriptPayload[]): ScriptTone {
|
||||
const serviceTones = running
|
||||
.filter((script) => script.type === "service")
|
||||
.map((script) => scriptStatus(script));
|
||||
if (serviceTones.includes("unhealthy")) return "unhealthy";
|
||||
if (serviceTones.length > 0 && serviceTones.every((tone) => tone === "healthy")) {
|
||||
return "healthy";
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
|
||||
const idleColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const runningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
|
||||
const healthyColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
const unhealthyColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||
|
||||
export function scriptToneColorMapping(tone: ScriptTone): (theme: Theme) => { color: string } {
|
||||
switch (tone) {
|
||||
case "idle":
|
||||
return idleColorMapping;
|
||||
case "running":
|
||||
return runningColorMapping;
|
||||
case "healthy":
|
||||
return healthyColorMapping;
|
||||
case "unhealthy":
|
||||
return unhealthyColorMapping;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
resolveWorkspaceScriptLink,
|
||||
type WorkspaceScriptLinkKind,
|
||||
type WorkspaceScriptLinkTarget,
|
||||
} from "@/utils/workspace-script-links";
|
||||
import { useWorkspaceServiceRoutePreferencesStore } from "@/workspace-service-routes/store";
|
||||
|
||||
type WorkspaceScript = WorkspaceDescriptor["scripts"][number];
|
||||
|
||||
export interface WorkspaceScriptLinks {
|
||||
/** The URL this host's route preference picks, or null when the script serves none. */
|
||||
selected: WorkspaceScriptLinkTarget | null;
|
||||
targets: WorkspaceScriptLinkTarget[];
|
||||
}
|
||||
|
||||
export interface WorkspaceScriptActions {
|
||||
start: (scriptName: string) => void;
|
||||
stop: (scriptName: string) => void;
|
||||
/** Stops the script, then starts it again once the daemon reports it stopped. */
|
||||
restart: (scriptName: string) => void;
|
||||
copyUrl: (target: WorkspaceScriptLinkTarget) => void;
|
||||
selectRoute: (kind: WorkspaceScriptLinkKind) => void;
|
||||
resolveLinks: (script: WorkspaceScript) => WorkspaceScriptLinks;
|
||||
isStartPending: boolean;
|
||||
isStopPending: boolean;
|
||||
}
|
||||
|
||||
export interface UseWorkspaceScriptActionsInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
onScriptTerminalStarted?: (terminalId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run, stop, restart and URL handling for a workspace's scripts, shared by every surface that
|
||||
* lists them.
|
||||
*
|
||||
* A restart outlives the press that asked for it — the second half waits for the daemon to
|
||||
* report the script stopped — so mount this with whatever owns the menu, not inside a menu page
|
||||
* that unmounts when the menu closes.
|
||||
*/
|
||||
export function useWorkspaceScriptActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
scripts,
|
||||
onScriptTerminalStarted,
|
||||
}: UseWorkspaceScriptActionsInput): WorkspaceScriptActions {
|
||||
const { t } = useTranslation();
|
||||
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 pendingRestartRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const startScriptMutation = useMutation({
|
||||
mutationFn: async (scriptName: string) => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
const result = await client.startWorkspaceScript(workspaceId, scriptName);
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onError: (error, scriptName) => {
|
||||
toast.show(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("workspace.scripts.states.startFailed", { scriptName }),
|
||||
{
|
||||
variant: "error",
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.terminalId) {
|
||||
onScriptTerminalStarted?.(result.terminalId);
|
||||
}
|
||||
},
|
||||
});
|
||||
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",
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const stopScript = stopScriptMutation.mutate;
|
||||
|
||||
// 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 restart = useCallback(
|
||||
(scriptName: string) => {
|
||||
pendingRestartRef.current.add(scriptName);
|
||||
stopScript(scriptName);
|
||||
},
|
||||
[stopScript],
|
||||
);
|
||||
|
||||
const copyUrl = useCallback(
|
||||
(target: WorkspaceScriptLinkTarget) => {
|
||||
void Clipboard.setStringAsync(target.url);
|
||||
toast.copied(target.label);
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const selectRoute = useCallback(
|
||||
(kind: WorkspaceScriptLinkKind) => setPreferredRoute(serverId, kind),
|
||||
[serverId, setPreferredRoute],
|
||||
);
|
||||
|
||||
const resolveLinks = useCallback(
|
||||
(script: WorkspaceScript): WorkspaceScriptLinks => {
|
||||
const link = resolveWorkspaceScriptLink({ script, activeConnection });
|
||||
const selected =
|
||||
link.targets.find((target) => target.kind === preferredRouteKind) ?? link.primary;
|
||||
return { selected, targets: link.targets };
|
||||
},
|
||||
[activeConnection, preferredRouteKind],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
start: startScript,
|
||||
stop: stopScript,
|
||||
restart,
|
||||
copyUrl,
|
||||
selectRoute,
|
||||
resolveLinks,
|
||||
isStartPending: startScriptMutation.isPending,
|
||||
isStopPending: stopScriptMutation.isPending,
|
||||
}),
|
||||
[
|
||||
startScript,
|
||||
stopScript,
|
||||
restart,
|
||||
copyUrl,
|
||||
selectRoute,
|
||||
resolveLinks,
|
||||
startScriptMutation.isPending,
|
||||
stopScriptMutation.isPending,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import React, { type ReactElement } from "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";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WorkspaceScriptsButton } from "./workspace-scripts-button";
|
||||
|
||||
void testI18n;
|
||||
|
||||
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 },
|
||||
borderRadius: { md: 6, lg: 8 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface2: "#222",
|
||||
borderAccent: "#444",
|
||||
palette: {
|
||||
blue: { 500: "#0a84ff" },
|
||||
green: { 500: "#30d158" },
|
||||
red: { 300: "#ff9f99", 500: "#ff453a" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: {
|
||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(theme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
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({
|
||||
sessions: {
|
||||
"test-server": {
|
||||
client: {
|
||||
startWorkspaceScript: startWorkspaceScriptMock,
|
||||
killTerminal: killTerminalMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => ({ show: vi.fn(), error: vi.fn(), copied: copiedToastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("expo-clipboard", () => ({
|
||||
setStringAsync: setStringAsyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||
<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,
|
||||
}: {
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" data-testid={testID}>
|
||||
{typeof children === "function"
|
||||
? children({ hovered: false, pressed: false, open: true })
|
||||
: children}
|
||||
</button>
|
||||
),
|
||||
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", {
|
||||
"data-icon": name,
|
||||
"data-color": props.color,
|
||||
"data-size": props.size,
|
||||
"data-testid": props.testID,
|
||||
});
|
||||
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"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("react-native");
|
||||
return actual;
|
||||
});
|
||||
|
||||
function script(
|
||||
input: Partial<WorkspaceScriptPayload> & Pick<WorkspaceScriptPayload, "scriptName">,
|
||||
): WorkspaceScriptPayload {
|
||||
return {
|
||||
scriptName: input.scriptName,
|
||||
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,
|
||||
exitCode: input.exitCode ?? null,
|
||||
terminalId: input.terminalId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const LIVE_TERMINAL_IDS: string[] = ["terminal-script-1"];
|
||||
|
||||
interface RenderScriptsOptions {
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
function renderScripts(
|
||||
scripts: WorkspaceScriptPayload[],
|
||||
options: RenderScriptsOptions = {},
|
||||
): {
|
||||
rerender: (nextScripts: WorkspaceScriptPayload[]) => Promise<void>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
function element(nextScripts: WorkspaceScriptPayload[]): ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceScriptsButton
|
||||
serverId="test-server"
|
||||
workspaceId="workspace-1"
|
||||
scripts={nextScripts}
|
||||
liveTerminalIds={LIVE_TERMINAL_IDS}
|
||||
hideLabels={options.hideLabels}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(element(scripts));
|
||||
});
|
||||
|
||||
return {
|
||||
rerender: async (nextScripts) => {
|
||||
await act(async () => {
|
||||
root.render(element(nextScripts));
|
||||
});
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireRow(scriptName: string): HTMLElement {
|
||||
const row = document.querySelector(`[data-testid="workspace-scripts-item-${scriptName}"]`);
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error(`Missing script row for ${scriptName}`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function requirePrimaryIcon(row: HTMLElement): HTMLElement {
|
||||
const icon = row.querySelector("[data-icon]");
|
||||
if (!(icon instanceof HTMLElement)) {
|
||||
throw new Error("Missing row icon");
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
describe("WorkspaceScriptsButton", () => {
|
||||
let current: ReturnType<typeof renderScripts> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
document.body.innerHTML = "";
|
||||
startWorkspaceScriptMock.mockClear();
|
||||
killTerminalMock.mockClear();
|
||||
setStringAsyncMock.mockClear();
|
||||
copiedToastMock.mockClear();
|
||||
setPreferredRouteMock.mockClear();
|
||||
for (const serverId of Object.keys(routePreferenceByServerIdMock)) {
|
||||
delete routePreferenceByServerIdMock[serverId];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
current?.unmount();
|
||||
current = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps completed script row icons visible and muted while the menu content stays mounted", async () => {
|
||||
current = renderScripts([
|
||||
script({
|
||||
scriptName: "typecheck",
|
||||
lifecycle: "running",
|
||||
terminalId: "terminal-script-1",
|
||||
}),
|
||||
]);
|
||||
|
||||
let row = requireRow("typecheck");
|
||||
let icon = requirePrimaryIcon(row);
|
||||
expect(icon.dataset.icon).toBe("SquareTerminal");
|
||||
expect(icon.dataset.color).toBe(theme.colors.palette.blue[500]);
|
||||
|
||||
await current.rerender([
|
||||
script({
|
||||
scriptName: "typecheck",
|
||||
lifecycle: "stopped",
|
||||
exitCode: 0,
|
||||
terminalId: "terminal-script-1",
|
||||
}),
|
||||
]);
|
||||
|
||||
row = requireRow("typecheck");
|
||||
icon = requirePrimaryIcon(row);
|
||||
expect(icon.dataset.icon).toBe("SquareTerminal");
|
||||
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
|
||||
expect(row.textContent).toContain("typecheck");
|
||||
expect(row.textContent).toContain("exit 0");
|
||||
expect(row.querySelector('[data-testid="workspace-scripts-start-typecheck"]')).not.toBeNull();
|
||||
|
||||
await current.rerender([
|
||||
script({
|
||||
scriptName: "typecheck",
|
||||
lifecycle: "stopped",
|
||||
exitCode: 7,
|
||||
terminalId: "terminal-script-1",
|
||||
}),
|
||||
]);
|
||||
|
||||
row = requireRow("typecheck");
|
||||
icon = requirePrimaryIcon(row);
|
||||
expect(icon.dataset.icon).toBe("SquareTerminal");
|
||||
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
|
||||
expect(row.textContent).toContain("exit 7");
|
||||
expect(row.querySelector('[data-testid="workspace-scripts-start-typecheck"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uses service icon color for service health and running unknown status only", () => {
|
||||
current = renderScripts([
|
||||
script({
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
port: 3000,
|
||||
}),
|
||||
script({
|
||||
scriptName: "api",
|
||||
type: "service",
|
||||
hostname: "api.paseo.localhost",
|
||||
lifecycle: "running",
|
||||
health: "unhealthy",
|
||||
port: 4000,
|
||||
}),
|
||||
script({
|
||||
scriptName: "worker",
|
||||
type: "service",
|
||||
hostname: "worker.paseo.localhost",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
port: 5000,
|
||||
}),
|
||||
script({
|
||||
scriptName: "old-service",
|
||||
type: "service",
|
||||
hostname: "old-service.paseo.localhost",
|
||||
lifecycle: "stopped",
|
||||
exitCode: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(requirePrimaryIcon(requireRow("web")).dataset.color).toBe(
|
||||
theme.colors.palette.green[500],
|
||||
);
|
||||
expect(requirePrimaryIcon(requireRow("api")).dataset.color).toBe(theme.colors.palette.red[500]);
|
||||
expect(requirePrimaryIcon(requireRow("worker")).dataset.color).toBe(
|
||||
theme.colors.palette.blue[500],
|
||||
);
|
||||
expect(requirePrimaryIcon(requireRow("old-service")).dataset.color).toBe(
|
||||
theme.colors.foregroundMuted,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the trigger caret in split presentation", () => {
|
||||
current = renderScripts([script({ scriptName: "dev" })]);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import type { GestureResponderEvent } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Eye,
|
||||
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";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
useDropdownMenuClose,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MENU_ITEM_HEIGHT } from "@/components/ui/menu/menu-geometry";
|
||||
import { openServiceUrl } from "@/utils/open-service-url";
|
||||
import type {
|
||||
WorkspaceScriptLinkKind,
|
||||
WorkspaceScriptLinkTarget,
|
||||
} from "@/utils/workspace-script-links";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks";
|
||||
import { scriptToneColorMapping } from "./script-status";
|
||||
import {
|
||||
useWorkspaceScriptActions,
|
||||
type WorkspaceScriptActions,
|
||||
} from "./use-workspace-script-actions";
|
||||
|
||||
type RowActionIcon = "copy" | "open" | "restart" | "start" | "stop" | "terminal";
|
||||
|
||||
interface WorkspaceScriptsButtonProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
liveTerminalIds?: readonly string[];
|
||||
onScriptTerminalStarted?: (terminalId: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab?: (url: string) => void;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
const ThemedPlay = withUnistyles(Play);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedEye = withUnistyles(Eye);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedSquare = withUnistyles(Square);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const mutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const playFillTransparent = { fill: "transparent" };
|
||||
|
||||
interface ScriptRowActionButtonProps {
|
||||
accessibilityLabel: string;
|
||||
disabled?: boolean;
|
||||
icon: RowActionIcon;
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
tooltipLabel: string;
|
||||
}
|
||||
|
||||
function RowActionIconElement({
|
||||
hovered,
|
||||
icon,
|
||||
}: {
|
||||
hovered?: boolean;
|
||||
icon: RowActionIcon;
|
||||
}): ReactElement {
|
||||
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
|
||||
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 ScriptRowActionButton({
|
||||
accessibilityLabel,
|
||||
disabled,
|
||||
icon,
|
||||
onPress,
|
||||
testID,
|
||||
tooltipLabel,
|
||||
}: ScriptRowActionButtonProps): ReactElement {
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
},
|
||||
[onPress],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => <RowActionIconElement hovered={hovered} icon={icon} />,
|
||||
[icon],
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceLinkRowProps {
|
||||
selectedTarget: WorkspaceScriptLinkTarget;
|
||||
targets: WorkspaceScriptLinkTarget[];
|
||||
scriptName: string;
|
||||
onSelectKind: (kind: WorkspaceScriptLinkKind) => void;
|
||||
onCopy: (target: WorkspaceScriptLinkTarget) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceRouteSelector({
|
||||
scriptName,
|
||||
selectedTarget,
|
||||
targets,
|
||||
onSelect,
|
||||
}: {
|
||||
scriptName: string;
|
||||
selectedTarget: WorkspaceScriptLinkTarget;
|
||||
targets: WorkspaceScriptLinkTarget[];
|
||||
onSelect: (kind: WorkspaceScriptLinkKind) => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const accessibilityLabel = t("workspace.scripts.accessibility.chooseUrl", { scriptName });
|
||||
|
||||
return (
|
||||
<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 } = selectedTarget;
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
closeMenu();
|
||||
onCopy(selectedTarget);
|
||||
}, [selectedTarget, 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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScriptRowProps {
|
||||
script: WorkspaceDescriptor["scripts"][number];
|
||||
liveTerminalIdSet: Set<string>;
|
||||
actions: WorkspaceScriptActions;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab?: (url: string) => void;
|
||||
}
|
||||
|
||||
function ScriptRow({
|
||||
script,
|
||||
liveTerminalIdSet,
|
||||
actions,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
}: ScriptRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isRunning = script.lifecycle === "running";
|
||||
const exitCode = script.exitCode ?? null;
|
||||
const { selected: selectedLink, targets: linkTargets } = actions.resolveLinks(script);
|
||||
const liveTerminalId =
|
||||
script.terminalId && liveTerminalIdSet.has(script.terminalId) ? script.terminalId : null;
|
||||
const showExitBadge = !isRunning && exitCode !== null;
|
||||
const { start, stop, restart } = actions;
|
||||
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);
|
||||
}, [liveTerminalId, onViewTerminal]);
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
start(script.scriptName);
|
||||
}, [start, script.scriptName]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stop(script.scriptName);
|
||||
}, [stop, script.scriptName]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
restart(script.scriptName);
|
||||
}, [restart, script.scriptName]);
|
||||
|
||||
const scriptNameStyle = useMemo(
|
||||
() => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName),
|
||||
[isRunning],
|
||||
);
|
||||
|
||||
const viewAction =
|
||||
isRunning && liveTerminalId ? (
|
||||
<ScriptRowActionButton
|
||||
accessibilityLabel={t("workspace.scripts.accessibility.viewTerminal", {
|
||||
scriptName: script.scriptName,
|
||||
})}
|
||||
testID={`workspace-scripts-view-${script.scriptName}`}
|
||||
icon="terminal"
|
||||
onPress={handleView}
|
||||
tooltipLabel={t("workspace.scripts.actions.view")}
|
||||
/>
|
||||
) : 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={actions.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={actions.isStartPending}
|
||||
icon="start"
|
||||
onPress={handleRun}
|
||||
tooltipLabel={t("workspace.scripts.actions.run")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
testID={`workspace-scripts-item-${script.scriptName}`}
|
||||
accessibilityLabel={t("workspace.scripts.accessibility.script", {
|
||||
scriptName: script.scriptName,
|
||||
})}
|
||||
>
|
||||
<View style={styles.scriptHeader}>
|
||||
<ScriptIcon script={script} size={14} />
|
||||
<Text style={scriptNameStyle} numberOfLines={1}>
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
{showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
|
||||
<View style={styles.spacer} />
|
||||
{openServiceAction}
|
||||
{viewAction}
|
||||
{isRunning ? (
|
||||
<ScriptRowActionButton
|
||||
accessibilityLabel={t("workspace.scripts.accessibility.restartScript", {
|
||||
scriptName: script.scriptName,
|
||||
})}
|
||||
testID={`workspace-scripts-restart-${script.scriptName}`}
|
||||
disabled={actions.isStopPending}
|
||||
icon="restart"
|
||||
onPress={handleRestart}
|
||||
tooltipLabel={t("workspace.scripts.actions.restart")}
|
||||
/>
|
||||
) : null}
|
||||
{lifecycleAction}
|
||||
</View>
|
||||
{selectedLink ? (
|
||||
<View style={styles.hostList}>
|
||||
<ServiceLinkRow
|
||||
selectedTarget={selectedLink}
|
||||
targets={linkTargets}
|
||||
scriptName={script.scriptName}
|
||||
onSelectKind={actions.selectRoute}
|
||||
onCopy={actions.copyUrl}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceScriptsButton({
|
||||
serverId,
|
||||
workspaceId,
|
||||
scripts,
|
||||
liveTerminalIds = [],
|
||||
onScriptTerminalStarted,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
hideLabels,
|
||||
}: WorkspaceScriptsButtonProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const actions = useWorkspaceScriptActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
scripts,
|
||||
onScriptTerminalStarted,
|
||||
});
|
||||
const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
|
||||
styles.splitButtonPrimary,
|
||||
(hovered || pressed || open) && styles.splitButtonPrimaryHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
if (scripts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The trigger only says whether anything runs; health is for the rows inside.
|
||||
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
|
||||
const triggerPlayMapping = scriptToneColorMapping(hasAnyRunning ? "running" : "idle");
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.splitButton}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-scripts-button"
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.scripts.accessibility.trigger")}
|
||||
>
|
||||
<View style={styles.splitButtonContent}>
|
||||
<ThemedPlay size={14} uniProps={triggerPlayMapping} {...playFillTransparent} />
|
||||
{!hideLabels && (
|
||||
<Text style={styles.splitButtonText}>{t("workspace.scripts.title")}</Text>
|
||||
)}
|
||||
<ThemedChevronDown size={16} uniProps={extraMutedIconColorMapping} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
minWidth={200}
|
||||
maxWidth={280}
|
||||
testID="workspace-scripts-menu"
|
||||
>
|
||||
{scripts.map((script) => (
|
||||
<ScriptRow
|
||||
key={script.scriptName}
|
||||
script={script}
|
||||
liveTerminalIdSet={liveTerminalIdSet}
|
||||
actions={actions}
|
||||
onViewTerminal={onViewTerminal}
|
||||
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
height: {
|
||||
xs: buttonControlHeight.xs,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
splitButtonPrimary: {
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
justifyContent: "center",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
splitButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.5,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
splitButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: {
|
||||
xs: theme.spacing[1.5],
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
minHeight: theme.fontSize.base * 1.5,
|
||||
},
|
||||
scriptHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
minHeight: MENU_ITEM_HEIGHT,
|
||||
},
|
||||
scriptName: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
scriptNameActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
hostList: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
hostRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
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.sm,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hostLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
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: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Copy, Eye, Play, RotateCw, Square, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSubTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openServiceUrl } from "@/utils/open-service-url";
|
||||
import type {
|
||||
WorkspaceScriptLinkKind,
|
||||
WorkspaceScriptLinkTarget,
|
||||
} from "@/utils/workspace-script-links";
|
||||
import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks";
|
||||
import { scriptToneColorMapping, summarizeScripts, type ScriptsSummary } from "./script-status";
|
||||
import {
|
||||
useWorkspaceScriptActions,
|
||||
type WorkspaceScriptActions,
|
||||
} from "./use-workspace-script-actions";
|
||||
|
||||
type WorkspaceScript = WorkspaceDescriptor["scripts"][number];
|
||||
|
||||
const ThemedPlay = withUnistyles(Play);
|
||||
const ThemedEye = withUnistyles(Eye);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedSquare = withUnistyles(Square);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const playFillTransparent = { fill: "transparent" };
|
||||
|
||||
const ROW_ICON_SIZE = 16;
|
||||
const TRAILING_ICON_SIZE = 14;
|
||||
|
||||
const OPEN_SERVICE_ICON = <ThemedEye size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const VIEW_TERMINAL_ICON = (
|
||||
<ThemedSquareTerminal size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />
|
||||
);
|
||||
const COPY_URL_ICON = <ThemedCopy size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const RESTART_ICON = <ThemedRotateCw size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const STOP_ICON = <ThemedSquare size={ROW_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const RUN_ICON = (
|
||||
<ThemedPlay size={ROW_ICON_SIZE} uniProps={mutedColorMapping} {...playFillTransparent} />
|
||||
);
|
||||
|
||||
const SCRIPTS_PAGE_ID = "workspace-scripts";
|
||||
|
||||
function scriptPageId(scriptName: string): string {
|
||||
return `${SCRIPTS_PAGE_ID}/${scriptName}`;
|
||||
}
|
||||
|
||||
export interface UseWorkspaceScriptsMenuPagesInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
liveTerminalIds: readonly string[];
|
||||
onScriptTerminalStarted: (terminalId: string) => void;
|
||||
onViewTerminal: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab: (url: string) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceScriptsMenuPages {
|
||||
/** The root row that leads to the scripts page. Null when the workspace has no scripts. */
|
||||
trigger: ReactElement | null;
|
||||
pages: MenuPageDefinition[];
|
||||
/** A running service reports unhealthy, so the menu's own trigger should say so. */
|
||||
needsAttention: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace scripts as pages of a sheet menu: a Scripts row on the root, a page listing every
|
||||
* script, and one page per script.
|
||||
*
|
||||
* Every script gets a page whether or not it runs, so stopping one from its own page flips that
|
||||
* page to its stopped state instead of pulling it out from under the user.
|
||||
*
|
||||
* The actions live here rather than in a page, so call this from whatever owns the menu: a restart
|
||||
* finishes after the sheet has closed.
|
||||
*/
|
||||
export function useWorkspaceScriptsMenuPages({
|
||||
serverId,
|
||||
workspaceId,
|
||||
scripts,
|
||||
liveTerminalIds,
|
||||
onScriptTerminalStarted,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
}: UseWorkspaceScriptsMenuPagesInput): WorkspaceScriptsMenuPages {
|
||||
const { t } = useTranslation();
|
||||
const actions = useWorkspaceScriptActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
scripts,
|
||||
onScriptTerminalStarted,
|
||||
});
|
||||
const summary = useMemo(() => summarizeScripts(scripts), [scripts]);
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (scripts.length === 0 ? null : <ScriptsMenuTrigger summary={summary} />),
|
||||
[scripts.length, summary],
|
||||
);
|
||||
|
||||
const pages = useMemo<MenuPageDefinition[]>(() => {
|
||||
if (scripts.length === 0) return [];
|
||||
const liveTerminalIdSet = new Set(liveTerminalIds);
|
||||
return [
|
||||
{
|
||||
id: SCRIPTS_PAGE_ID,
|
||||
title: t("workspace.scripts.title"),
|
||||
content: <ScriptsPage scripts={scripts} actions={actions} />,
|
||||
},
|
||||
...scripts.map((script) => ({
|
||||
id: scriptPageId(script.scriptName),
|
||||
title: script.scriptName,
|
||||
content: (
|
||||
<ScriptPage
|
||||
script={script}
|
||||
actions={actions}
|
||||
liveTerminalId={
|
||||
script.terminalId && liveTerminalIdSet.has(script.terminalId)
|
||||
? script.terminalId
|
||||
: null
|
||||
}
|
||||
onViewTerminal={onViewTerminal}
|
||||
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
];
|
||||
}, [actions, liveTerminalIds, onOpenUrlInBrowserTab, onViewTerminal, scripts, t]);
|
||||
|
||||
return { trigger, pages, needsAttention: summary.tone === "unhealthy" };
|
||||
}
|
||||
|
||||
/** The root row: ▶ in the colour of the whole set, and what is running as its value. */
|
||||
function ScriptsMenuTrigger({ summary }: { summary: ScriptsSummary }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPlay
|
||||
size={ROW_ICON_SIZE}
|
||||
uniProps={scriptToneColorMapping(summary.tone)}
|
||||
{...playFillTransparent}
|
||||
/>
|
||||
),
|
||||
[summary.tone],
|
||||
);
|
||||
const [onlyRunning] = summary.running;
|
||||
const value =
|
||||
summary.running.length > 1
|
||||
? t("workspace.scripts.states.runningCount", { count: summary.running.length })
|
||||
: onlyRunning;
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
id={SCRIPTS_PAGE_ID}
|
||||
value={value}
|
||||
leading={leading}
|
||||
testID="workspace-header-scripts"
|
||||
>
|
||||
{t("workspace.scripts.title")}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptsPage({
|
||||
scripts,
|
||||
actions,
|
||||
}: {
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
actions: WorkspaceScriptActions;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{scripts.map((script) =>
|
||||
script.lifecycle === "running" ? (
|
||||
<RunningScriptRow key={script.scriptName} script={script} />
|
||||
) : (
|
||||
<StoppedScriptRow key={script.scriptName} script={script} actions={actions} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunningScriptRow({ script }: { script: WorkspaceScript }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const leading = useMemo(() => <ScriptIcon script={script} size={ROW_ICON_SIZE} />, [script]);
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
id={scriptPageId(script.scriptName)}
|
||||
value={t("workspace.scripts.states.running")}
|
||||
leading={leading}
|
||||
testID={`workspace-scripts-row-${script.scriptName}`}
|
||||
>
|
||||
{script.scriptName}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pressing a stopped script runs it. Starting opens its terminal tab, so the sheet closes. */
|
||||
function StoppedScriptRow({
|
||||
script,
|
||||
actions,
|
||||
}: {
|
||||
script: WorkspaceScript;
|
||||
actions: WorkspaceScriptActions;
|
||||
}): ReactElement {
|
||||
const { start } = actions;
|
||||
const handleSelect = useCallback(() => start(script.scriptName), [start, script.scriptName]);
|
||||
const exitCode = script.exitCode ?? null;
|
||||
const leading = useMemo(() => <ScriptIcon script={script} size={ROW_ICON_SIZE} />, [script]);
|
||||
const trailing = useMemo(
|
||||
() => (
|
||||
<View style={styles.trailing}>
|
||||
{exitCode === null ? null : <ExitCodeBadge code={exitCode} />}
|
||||
<ThemedPlay
|
||||
size={TRAILING_ICON_SIZE}
|
||||
uniProps={mutedColorMapping}
|
||||
{...playFillTransparent}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[exitCode],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
disabled={actions.isStartPending}
|
||||
onSelect={handleSelect}
|
||||
testID={`workspace-scripts-row-${script.scriptName}`}
|
||||
>
|
||||
{script.scriptName}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScriptPageProps {
|
||||
script: WorkspaceScript;
|
||||
actions: WorkspaceScriptActions;
|
||||
liveTerminalId: string | null;
|
||||
onViewTerminal: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab: (url: string) => void;
|
||||
}
|
||||
|
||||
function ScriptPage(props: ScriptPageProps): ReactElement {
|
||||
return props.script.lifecycle === "running" ? (
|
||||
<RunningScriptPage {...props} />
|
||||
) : (
|
||||
<StoppedScriptPage script={props.script} actions={props.actions} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that open something — the service, the terminal, the clipboard toast — close the sheet.
|
||||
* Stop and the URL choice stay: the page is where their result shows.
|
||||
*/
|
||||
function RunningScriptPage({
|
||||
script,
|
||||
actions,
|
||||
liveTerminalId,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
}: ScriptPageProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { scriptName } = script;
|
||||
const { selected, targets } = actions.resolveLinks(script);
|
||||
const { stop, restart, copyUrl, selectRoute } = actions;
|
||||
|
||||
const handleOpenService = useCallback(() => {
|
||||
if (!selected) return;
|
||||
void openServiceUrl(selected.url, { openInApp: onOpenUrlInBrowserTab });
|
||||
}, [selected, onOpenUrlInBrowserTab]);
|
||||
const handleViewTerminal = useCallback(() => {
|
||||
if (liveTerminalId) onViewTerminal(liveTerminalId);
|
||||
}, [liveTerminalId, onViewTerminal]);
|
||||
const handleCopyUrl = useCallback(() => {
|
||||
if (selected) copyUrl(selected);
|
||||
}, [selected, copyUrl]);
|
||||
const handleRestart = useCallback(() => restart(scriptName), [restart, scriptName]);
|
||||
const handleStop = useCallback(() => stop(scriptName), [stop, scriptName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected ? (
|
||||
<DropdownMenuItem
|
||||
leading={OPEN_SERVICE_ICON}
|
||||
onSelect={handleOpenService}
|
||||
testID={`workspace-script-${scriptName}-open`}
|
||||
>
|
||||
{t("workspace.scripts.actions.openService")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{liveTerminalId ? (
|
||||
<DropdownMenuItem
|
||||
leading={VIEW_TERMINAL_ICON}
|
||||
onSelect={handleViewTerminal}
|
||||
testID={`workspace-script-${scriptName}-view`}
|
||||
>
|
||||
{t("workspace.scripts.actions.view")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<DropdownMenuItem
|
||||
leading={COPY_URL_ICON}
|
||||
description={selected.label}
|
||||
onSelect={handleCopyUrl}
|
||||
testID={`workspace-script-${scriptName}-copy`}
|
||||
>
|
||||
{t("workspace.scripts.actions.copyUrl")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
leading={RESTART_ICON}
|
||||
disabled={actions.isStopPending}
|
||||
onSelect={handleRestart}
|
||||
testID={`workspace-script-${scriptName}-restart`}
|
||||
>
|
||||
{t("workspace.scripts.actions.restart")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
leading={STOP_ICON}
|
||||
disabled={actions.isStopPending}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleStop}
|
||||
testID={`workspace-script-${scriptName}-stop`}
|
||||
>
|
||||
{t("workspace.scripts.actions.stop")}
|
||||
</DropdownMenuItem>
|
||||
{selected && targets.length > 1 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{targets.map((target) => (
|
||||
<ServiceRouteOption
|
||||
key={target.kind}
|
||||
scriptName={scriptName}
|
||||
target={target}
|
||||
selected={target.kind === selected.kind}
|
||||
onSelect={selectRoute}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pick-one URL route. Choosing one keeps the page open so the check moves under the thumb. */
|
||||
function ServiceRouteOption({
|
||||
scriptName,
|
||||
target,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
scriptName: string;
|
||||
target: WorkspaceScriptLinkTarget;
|
||||
selected: boolean;
|
||||
onSelect: (kind: WorkspaceScriptLinkKind) => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => onSelect(target.kind), [onSelect, target.kind]);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
selected={selected}
|
||||
showSelectedCheck
|
||||
description={target.label}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`workspace-script-${scriptName}-route-${target.kind}`}
|
||||
>
|
||||
{t(routeLabelKey(target.kind))}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function StoppedScriptPage({
|
||||
script,
|
||||
actions,
|
||||
}: {
|
||||
script: WorkspaceScript;
|
||||
actions: WorkspaceScriptActions;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { start } = actions;
|
||||
const handleRun = useCallback(() => start(script.scriptName), [start, script.scriptName]);
|
||||
const exitCode = script.exitCode ?? null;
|
||||
const trailing = useMemo(
|
||||
() => (exitCode === null ? null : <ExitCodeBadge code={exitCode} />),
|
||||
[exitCode],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
leading={RUN_ICON}
|
||||
trailing={trailing}
|
||||
disabled={actions.isStartPending}
|
||||
onSelect={handleRun}
|
||||
testID={`workspace-script-${script.scriptName}-run`}
|
||||
>
|
||||
{t("workspace.scripts.actions.run")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user