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 { CONTROL_HEIGHTS, 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 ;
case "open":
return ;
case "restart":
return ;
case "start":
return ;
case "stop":
return ;
case "terminal":
return ;
}
}
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 }) => ,
[icon],
);
return (
{renderChildren}
{tooltipLabel}
);
}
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 (
{t(routeLabelKey(target.kind))}
);
}
function ServiceRouteTriggerContent({
hovered,
label,
}: {
hovered: boolean;
label: string;
}): ReactElement {
return (
<>
{label}
>
);
}
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 (
{({ hovered }) => (
)}
{t("workspace.scripts.actions.chooseUrl")}
{targets.map((target) => (
))}
);
}
function ServiceLinkRow({
selectedTarget,
targets,
scriptName,
onSelectKind,
onCopy,
}: ServiceLinkRowProps): ReactElement {
const { t } = useTranslation();
const closeMenu = useDropdownMenuClose();
const { label } = selectedTarget;
const handleCopy = useCallback(() => {
closeMenu();
onCopy(selectedTarget);
}, [selectedTarget, onCopy, closeMenu]);
return (
{targets.length > 1 ? (
) : (
{label}
)}
);
}
interface ScriptRowProps {
script: WorkspaceDescriptor["scripts"][number];
liveTerminalIdSet: Set;
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 ? (
) : null;
const openServiceAction = selectedLink ? (
) : null;
const lifecycleAction = isRunning ? (
) : (
);
return (
{script.scriptName}
{showExitBadge ? : null}
{openServiceAction}
{viewAction}
{isRunning ? (
) : null}
{lifecycleAction}
{selectedLink ? (
) : null}
);
}
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 (
{!hideLabels && (
{t("workspace.scripts.title")}
)}
{scripts.map((script) => (
))}
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
height: {
xs: CONTROL_HEIGHTS.sm,
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,
},
}));