refactor: rename services to scripts and extract reusable components
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.additions}>+{additions.toLocaleString()}</Text>
|
||||
<Text style={styles.deletions}>-{deletions.toLocaleString()}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
}));
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -474,8 +475,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.fileHeaderRight}>
|
||||
<Text style={styles.additions}>+{file.additions}</Text>
|
||||
<Text style={styles.deletions}>-{file.deletions}</Text>
|
||||
<DiffStat additions={file.additions} deletions={file.deletions} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -1738,16 +1738,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
diffContent: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
Archive,
|
||||
ArrowUpRight,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
FolderPlus,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Terminal,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
@@ -94,7 +95,8 @@ import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { CheckStatusIndicator, WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
@@ -233,6 +235,38 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
const failed = checks.filter((c) => c.status === "failure").length;
|
||||
if (failed === 0) return null;
|
||||
|
||||
const color = theme.colors.palette.red[500];
|
||||
const label = `${failed} failed`;
|
||||
|
||||
return (
|
||||
<View style={checksBadgeStyles.badge}>
|
||||
<GitHubIcon size={10} color={color} />
|
||||
<Text style={[checksBadgeStyles.text, { color }]}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const checksBadgeStyles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
function WorkspaceStatusIndicator({
|
||||
bucket,
|
||||
@@ -932,7 +966,7 @@ function WorkspaceRowInner({
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showGlobe = isDesktop && workspace.hasRunningServices;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
||||
@@ -980,9 +1014,9 @@ function WorkspaceRowInner({
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{showGlobe ? (
|
||||
<View testID="workspace-globe-icon" accessibilityLabel="Services available">
|
||||
<Globe size={12} color={theme.colors.foregroundMuted} />
|
||||
{showScriptsIcon ? (
|
||||
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
|
||||
<Terminal size={12} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
@@ -1037,10 +1071,10 @@ function WorkspaceRowInner({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
@@ -1051,7 +1085,7 @@ function WorkspaceRowInner({
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<CheckStatusIndicator status={prHint.checksStatus ?? "none"} size={12} />
|
||||
<ChecksBadge checks={prHint.checks} />
|
||||
<PrBadge hint={prHint} />
|
||||
</View>
|
||||
) : null}
|
||||
@@ -2431,22 +2465,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
|
||||
@@ -10,7 +10,9 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { Dimensions, Platform, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, ExternalLink, LoaderCircle, Minus, Play, X } from "lucide-react-native";
|
||||
import { ExternalLink, LoaderCircle, Play, Terminal } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { Pressable } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
@@ -19,6 +21,7 @@ import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||
|
||||
interface Rect {
|
||||
@@ -105,8 +108,8 @@ function WorkspaceHoverCardDesktop({
|
||||
const triggerHoveredRef = useRef(false);
|
||||
const contentHoveredRef = useRef(false);
|
||||
|
||||
const hasServices = workspace.services.length > 0;
|
||||
const hasContent = hasServices || prHint !== null;
|
||||
const hasScripts = workspace.scripts.length > 0;
|
||||
const hasContent = hasScripts || prHint !== null;
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
@@ -192,8 +195,8 @@ function WorkspaceHoverCardDesktop({
|
||||
);
|
||||
}
|
||||
|
||||
function getServiceHealthColor(input: {
|
||||
health: SidebarWorkspaceEntry["services"][number]["health"];
|
||||
function getScriptHealthColor(input: {
|
||||
health: SidebarWorkspaceEntry["scripts"][number]["health"];
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}): string {
|
||||
if (input.health === "healthy") {
|
||||
@@ -205,8 +208,8 @@ function getServiceHealthColor(input: {
|
||||
return input.theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
function getServiceHealthLabel(
|
||||
health: SidebarWorkspaceEntry["services"][number]["health"],
|
||||
function getScriptHealthLabel(
|
||||
health: SidebarWorkspaceEntry["scripts"][number]["health"],
|
||||
): "Healthy" | "Unhealthy" | "Unknown" {
|
||||
if (health === "healthy") {
|
||||
return "Healthy";
|
||||
@@ -218,93 +221,6 @@ function getServiceHealthLabel(
|
||||
}
|
||||
|
||||
|
||||
function getCheckStatusColor(input: {
|
||||
status: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}): string {
|
||||
if (input.status === "success") return input.theme.colors.palette.green[500];
|
||||
if (input.status === "failure") return input.theme.colors.palette.red[500];
|
||||
if (input.status === "pending") return input.theme.colors.palette.amber[500];
|
||||
return input.theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
function getCheckStatusIcon(status: string): typeof Check {
|
||||
if (status === "success") return Check;
|
||||
if (status === "failure") return X;
|
||||
return Minus;
|
||||
}
|
||||
|
||||
export function CheckStatusIndicator({
|
||||
status,
|
||||
size = 12,
|
||||
}: {
|
||||
status: string;
|
||||
size?: number;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (!status || status === "none") return null;
|
||||
|
||||
const color = getCheckStatusColor({ status, theme });
|
||||
const IconComponent = getCheckStatusIcon(status);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: 1,
|
||||
borderColor: color,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<IconComponent size={Math.round(size * 0.5)} color={color} strokeWidth={3} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksSummary({ checks }: { checks: Array<{ status: string }> }): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const counts: Record<string, number> = {};
|
||||
for (const check of checks) {
|
||||
const bucket = check.status === "success" ? "success" : check.status === "failure" ? "failure" : "pending";
|
||||
counts[bucket] = (counts[bucket] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const buckets: Array<{ status: string; count: number }> = [];
|
||||
if (counts.failure) buckets.push({ status: "failure", count: counts.failure });
|
||||
if (counts.success) buckets.push({ status: "success", count: counts.success });
|
||||
if (counts.pending) buckets.push({ status: "pending", count: counts.pending });
|
||||
|
||||
return (
|
||||
<>
|
||||
{buckets.map((bucket) => {
|
||||
const color = getCheckStatusColor({ status: bucket.status, theme });
|
||||
return (
|
||||
<View key={bucket.status} style={checksSummaryStyles.item}>
|
||||
<Text style={[checksSummaryStyles.count, { color }]}>{bucket.count}</Text>
|
||||
<CheckStatusIndicator status={bucket.status} size={12} />
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const checksSummaryStyles = StyleSheet.create((theme) => ({
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
count: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
}));
|
||||
|
||||
function WorkspaceHoverCardContent({
|
||||
workspace,
|
||||
prHint,
|
||||
@@ -325,20 +241,29 @@ function WorkspaceHoverCardContent({
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const startServiceMutation = useMutation({
|
||||
mutationFn: async (serviceName: string) => {
|
||||
const startScriptMutation = useMutation({
|
||||
mutationFn: async (scriptName: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const result = await client.startWorkspaceService(workspace.workspaceId, serviceName);
|
||||
const result = await client.startWorkspaceScript(workspace.workspaceId, scriptName);
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onError: (error, serviceName) => {
|
||||
onSuccess: (data) => {
|
||||
if (data.terminalId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
target: { kind: "terminal", terminalId: data.terminalId },
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error, scriptName) => {
|
||||
toast.show(
|
||||
error instanceof Error ? error.message : `Failed to start ${serviceName}`,
|
||||
error instanceof Error ? error.message : `Failed to start ${scriptName}`,
|
||||
{ variant: "error" },
|
||||
);
|
||||
},
|
||||
@@ -392,7 +317,7 @@ function WorkspaceHoverCardContent({
|
||||
onPointerEnter={onContentEnter}
|
||||
onPointerLeave={onContentLeave}
|
||||
accessibilityRole="menu"
|
||||
accessibilityLabel="Workspace services"
|
||||
accessibilityLabel="Workspace scripts"
|
||||
testID="workspace-hover-card"
|
||||
style={[
|
||||
styles.card,
|
||||
@@ -412,44 +337,47 @@ function WorkspaceHoverCardContent({
|
||||
{prHint || workspace.diffStat ? (
|
||||
<View style={styles.cardMetaRow}>
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
{prHint ? <PrBadge hint={prHint} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{workspace.services.length > 0 ? (
|
||||
{workspace.scripts.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<Text style={styles.sectionLabel}>Services</Text>
|
||||
<View style={styles.sectionList} testID="hover-card-service-list">
|
||||
{workspace.services.map((service) => {
|
||||
const isRunning = service.lifecycle === "running";
|
||||
const isLinkable = isRunning && !!service.url;
|
||||
<View style={styles.sectionLabelRow}>
|
||||
<Terminal size={12} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sectionLabel}>Scripts</Text>
|
||||
</View>
|
||||
<View style={styles.sectionList} testID="hover-card-script-list">
|
||||
{workspace.scripts.map((script) => {
|
||||
const isRunning = script.lifecycle === "running";
|
||||
const isLinkable = isRunning && !!script.url;
|
||||
return (
|
||||
<Pressable
|
||||
key={service.hostname}
|
||||
key={script.hostname}
|
||||
accessibilityRole={isLinkable ? "link" : undefined}
|
||||
accessibilityLabel={`${service.serviceName} service — ${isRunning ? getServiceHealthLabel(service.health) : "Stopped"}`}
|
||||
testID={`hover-card-service-${service.serviceName}`}
|
||||
accessibilityLabel={`${script.scriptName} script — ${isRunning ? getScriptHealthLabel(script.health) : "Stopped"}`}
|
||||
testID={`hover-card-script-${script.scriptName}`}
|
||||
style={({ hovered }) => [
|
||||
styles.listRow,
|
||||
hovered && isLinkable && styles.listRowHovered,
|
||||
]}
|
||||
onPress={isLinkable ? () => void openExternalUrl(service.url!) : undefined}
|
||||
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
|
||||
disabled={!isLinkable}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View
|
||||
testID={`hover-card-service-health-${service.serviceName}`}
|
||||
testID={`hover-card-script-health-${script.scriptName}`}
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: isRunning
|
||||
? getServiceHealthColor({ health: service.health, theme })
|
||||
? getScriptHealthColor({ health: script.health, theme })
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
@@ -465,44 +393,68 @@ function WorkspaceHoverCardContent({
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{service.serviceName}
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
{isRunning && service.url ? (
|
||||
{isRunning && script.url ? (
|
||||
<Text style={styles.listRowSecondary} numberOfLines={1}>
|
||||
{service.url.replace(/^https?:\/\//, "")}
|
||||
{script.url.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.listRowSpacer} />
|
||||
)}
|
||||
{isRunning ? (
|
||||
service.url ? (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
script.url && hovered ? (
|
||||
<View
|
||||
style={[
|
||||
styles.externalLinkOverlay,
|
||||
{
|
||||
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ExternalLink
|
||||
size={12}
|
||||
color={theme.colors.foreground}
|
||||
/>
|
||||
</View>
|
||||
) : null
|
||||
) : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Start ${service.serviceName} service`}
|
||||
testID={`hover-card-service-start-${service.serviceName}`}
|
||||
accessibilityLabel={`Run ${script.scriptName} script`}
|
||||
testID={`hover-card-script-start-${script.scriptName}`}
|
||||
hitSlop={4}
|
||||
disabled={startServiceMutation.isPending}
|
||||
disabled={startScriptMutation.isPending}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
startServiceMutation.mutate(service.serviceName);
|
||||
startScriptMutation.mutate(script.scriptName);
|
||||
}}
|
||||
style={styles.startButton}
|
||||
>
|
||||
{({ hovered: actionHovered }) =>
|
||||
startServiceMutation.isPending &&
|
||||
startServiceMutation.variables === service.serviceName ? (
|
||||
startScriptMutation.isPending &&
|
||||
startScriptMutation.variables === script.scriptName ? (
|
||||
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Play
|
||||
size={12}
|
||||
color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
fill="transparent"
|
||||
/>
|
||||
<>
|
||||
<Play
|
||||
size={10}
|
||||
color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
fill="transparent"
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.startButtonLabel,
|
||||
{
|
||||
color: actionHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Run
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
@@ -525,18 +477,53 @@ function WorkspaceHoverCardContent({
|
||||
]}
|
||||
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Text style={styles.checksSummaryLabel}>Checks</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
<ChecksSummary checks={prHint.checks!} />
|
||||
</View>
|
||||
<ExternalLink
|
||||
size={12}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{({ hovered }) => {
|
||||
const checks = prHint.checks!;
|
||||
const failed = checks.filter((c) => c.status === "failure").length;
|
||||
const pending = checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
let badgeColor: string;
|
||||
let badgeLabel: string;
|
||||
|
||||
if (failed > 0) {
|
||||
badgeColor = theme.colors.palette.red[500];
|
||||
badgeLabel = `${failed} failed`;
|
||||
} else if (pending > 0) {
|
||||
badgeColor = theme.colors.palette.amber[500];
|
||||
badgeLabel = `${pending} running`;
|
||||
} else {
|
||||
badgeColor = theme.colors.palette.green[500];
|
||||
badgeLabel = `${checks.length} passed`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GitHubIcon size={12} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.checksSummaryLabel}>Checks</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
|
||||
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
|
||||
{badgeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{hovered ? (
|
||||
<View
|
||||
style={[
|
||||
styles.externalLinkOverlay,
|
||||
{
|
||||
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ExternalLink
|
||||
size={12}
|
||||
color={theme.colors.foreground}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
@@ -560,7 +547,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
@@ -589,33 +576,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
sectionLabelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sectionList: {
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
@@ -630,13 +607,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
listRowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
externalLinkOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[3],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
listRowLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
flexShrink: 0,
|
||||
},
|
||||
listRowSecondary: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 18,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
textAlign: "right",
|
||||
@@ -650,25 +640,44 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
flexShrink: 0,
|
||||
marginLeft: 2,
|
||||
},
|
||||
checksSummaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: 6,
|
||||
minHeight: 28,
|
||||
},
|
||||
checksSummaryLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
checksSummaryCounts: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
checksDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
checksStatusText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
startButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
startButtonLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceServicePayload } from "@server/shared/messages";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { patchWorkspaceServices } from "./session-workspace-services";
|
||||
import { patchWorkspaceScripts } from "./session-workspace-scripts";
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
services?: WorkspaceDescriptor["services"];
|
||||
scripts?: WorkspaceDescriptor["scripts"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
@@ -19,12 +19,12 @@ function workspace(input: {
|
||||
status: "running",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
services: input.services ?? [],
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const runningService: WorkspaceServicePayload = {
|
||||
serviceName: "web",
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
@@ -32,21 +32,21 @@ const runningService: WorkspaceServicePayload = {
|
||||
health: "healthy",
|
||||
};
|
||||
|
||||
describe("patchWorkspaceServices", () => {
|
||||
it("patches only the matching workspace services", () => {
|
||||
const other = workspace({ id: "/repo/other", services: [] });
|
||||
describe("patchWorkspaceScripts", () => {
|
||||
it("patches only the matching workspace scripts", () => {
|
||||
const other = workspace({ id: "/repo/other", scripts: [] });
|
||||
const current = new Map<string, WorkspaceDescriptor>([
|
||||
["/repo/main", workspace({ id: "/repo/main", services: [] })],
|
||||
["/repo/main", workspace({ id: "/repo/main", scripts: [] })],
|
||||
[other.id, other],
|
||||
]);
|
||||
|
||||
const next = patchWorkspaceServices(current, {
|
||||
const next = patchWorkspaceScripts(current, {
|
||||
workspaceId: "/repo/main",
|
||||
services: [runningService],
|
||||
scripts: [runningScript],
|
||||
});
|
||||
|
||||
expect(next).not.toBe(current);
|
||||
expect(next.get("/repo/main")?.services).toEqual([runningService]);
|
||||
expect(next.get("/repo/main")?.scripts).toEqual([runningScript]);
|
||||
expect(next.get("/repo/other")).toBe(other);
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("patchWorkspaceServices", () => {
|
||||
"42",
|
||||
workspace({
|
||||
id: "42",
|
||||
services: [],
|
||||
scripts: [],
|
||||
}),
|
||||
],
|
||||
]);
|
||||
@@ -66,26 +66,26 @@ describe("patchWorkspaceServices", () => {
|
||||
workspaceDirectory: "C:\\repo\\main\\",
|
||||
});
|
||||
|
||||
const next = patchWorkspaceServices(current, {
|
||||
const next = patchWorkspaceScripts(current, {
|
||||
workspaceId: "C:/repo/main",
|
||||
services: [runningService],
|
||||
scripts: [runningScript],
|
||||
});
|
||||
|
||||
expect(next).not.toBe(current);
|
||||
expect(next.get("42")?.services).toEqual([runningService]);
|
||||
expect(next.get("42")?.scripts).toEqual([runningScript]);
|
||||
});
|
||||
|
||||
it("ignores updates for unknown workspaces", () => {
|
||||
const current = new Map<string, WorkspaceDescriptor>([
|
||||
["/repo/main", workspace({ id: "/repo/main", services: [] })],
|
||||
["/repo/main", workspace({ id: "/repo/main", scripts: [] })],
|
||||
]);
|
||||
|
||||
const next = patchWorkspaceServices(current, {
|
||||
const next = patchWorkspaceScripts(current, {
|
||||
workspaceId: "/repo/missing",
|
||||
services: [runningService],
|
||||
scripts: [runningScript],
|
||||
});
|
||||
|
||||
expect(next).toBe(current);
|
||||
expect(next.get("/repo/main")?.services).toEqual([]);
|
||||
expect(next.get("/repo/main")?.scripts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceServices } from "@/contexts/session-workspace-services";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -1168,9 +1168,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
});
|
||||
|
||||
const unsubServiceStatusUpdate = client.on("service_status_update", (message) => {
|
||||
if (message.type !== "service_status_update") return;
|
||||
setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload));
|
||||
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
|
||||
if (message.type !== "script_status_update") return;
|
||||
setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload));
|
||||
});
|
||||
|
||||
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
|
||||
@@ -1538,7 +1538,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubServiceStatusUpdate();
|
||||
unsubScriptStatusUpdate();
|
||||
unsubWorkspaceSetupProgress();
|
||||
unsubWorkspaceSetupStatusResponse();
|
||||
unsubStatus();
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import type { ServiceStatusUpdateMessage } from "@server/shared/messages";
|
||||
import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
|
||||
export function patchWorkspaceServices(
|
||||
export function patchWorkspaceScripts(
|
||||
workspaces: Map<string, WorkspaceDescriptor>,
|
||||
update: ServiceStatusUpdateMessage["payload"],
|
||||
update: ScriptStatusUpdateMessage["payload"],
|
||||
): Map<string, WorkspaceDescriptor> {
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
@@ -22,7 +22,7 @@ export function patchWorkspaceServices(
|
||||
const next = new Map(workspaces);
|
||||
next.set(workspaceKey, {
|
||||
...existing,
|
||||
services: update.services.map((s) => ({ ...s })),
|
||||
scripts: update.scripts.map((s) => ({ ...s })),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ describe("openProjectDirectly", () => {
|
||||
status: "done" as const,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
services: [],
|
||||
scripts: [],
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceServicePayload } from "@server/shared/messages";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
@@ -25,7 +25,7 @@ function workspace(
|
||||
| "workspaceDirectory"
|
||||
| "projectKind"
|
||||
| "workspaceKind"
|
||||
| "services"
|
||||
| "scripts"
|
||||
>
|
||||
>,
|
||||
): WorkspaceDescriptor {
|
||||
@@ -41,12 +41,12 @@ function workspace(
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
diffStat: null,
|
||||
services: input.services ?? [],
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const runningService: WorkspaceServicePayload = {
|
||||
serviceName: "web",
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
@@ -54,8 +54,8 @@ const runningService: WorkspaceServicePayload = {
|
||||
health: "healthy",
|
||||
};
|
||||
|
||||
const stoppedService: WorkspaceServicePayload = {
|
||||
serviceName: "api",
|
||||
const stoppedScript: WorkspaceScriptPayload = {
|
||||
scriptName: "api",
|
||||
hostname: "main.api.localhost",
|
||||
port: 3001,
|
||||
url: "http://main.api.localhost:6767",
|
||||
@@ -143,7 +143,7 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
|
||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
|
||||
});
|
||||
|
||||
it("threads services into workspace rows and derives hasRunningServices", () => {
|
||||
it("threads scripts into workspace rows and derives hasRunningScripts", () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
@@ -153,15 +153,15 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
|
||||
name: "main",
|
||||
status: "running",
|
||||
activityAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
services: [runningService, stoppedService],
|
||||
scripts: [runningScript, stoppedScript],
|
||||
}),
|
||||
],
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]);
|
||||
expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true);
|
||||
expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]);
|
||||
expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves stored project order even when activity changes", () => {
|
||||
|
||||
@@ -28,8 +28,8 @@ export interface SidebarWorkspaceEntry {
|
||||
activityAt: Date | null;
|
||||
statusBucket: SidebarStateBucket;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
services: WorkspaceDescriptor["services"];
|
||||
hasRunningServices: boolean;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
hasRunningScripts: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
@@ -148,8 +148,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
services: workspace.services,
|
||||
hasRunningServices: workspace.services.some((service) => service.lifecycle === "running"),
|
||||
scripts: workspace.scripts,
|
||||
hasRunningScripts: workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
|
||||
project.workspaces.push(row);
|
||||
@@ -273,11 +273,11 @@ function toWorkspaceDescriptor(payload: {
|
||||
name: string;
|
||||
status: WorkspaceDescriptor["status"];
|
||||
activityAt: string | null;
|
||||
services?: WorkspaceDescriptorPayload["services"];
|
||||
scripts?: WorkspaceDescriptorPayload["scripts"];
|
||||
}): WorkspaceDescriptor {
|
||||
return normalizeWorkspaceDescriptor({
|
||||
...payload,
|
||||
services: payload.services ?? [],
|
||||
scripts: payload.scripts ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
CopyX,
|
||||
ArrowLeftToLine,
|
||||
@@ -50,6 +51,7 @@ import { SplitContainer } from "@/components/split-container";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -2105,6 +2107,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
|
||||
<WorkspaceScriptsButton
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
scripts={workspaceDescriptor.scripts}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
{workspaceDirectory ? (
|
||||
@@ -2142,14 +2151,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
color={iconColor}
|
||||
/>
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>
|
||||
+{workspaceDescriptor.diffStat.additions}
|
||||
</Text>
|
||||
<Text style={styles.diffStatDeletions}>
|
||||
-{workspaceDescriptor.diffStat.deletions}
|
||||
</Text>
|
||||
</View>
|
||||
<DiffStat
|
||||
additions={workspaceDescriptor.diffStat.additions}
|
||||
deletions={workspaceDescriptor.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -2417,22 +2422,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sourceControlButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
newTabActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ChevronDown, ExternalLink, LoaderCircle, Play, Terminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
type Script = WorkspaceDescriptor["scripts"][number];
|
||||
|
||||
function getScriptHealthColor(
|
||||
health: Script["health"],
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): string {
|
||||
if (health === "healthy") {
|
||||
return theme.colors.palette.green[500];
|
||||
}
|
||||
if (health === "unhealthy") {
|
||||
return theme.colors.palette.red[500];
|
||||
}
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
interface WorkspaceScriptsButtonProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
}
|
||||
|
||||
export function WorkspaceScriptsButton({
|
||||
serverId,
|
||||
workspaceId,
|
||||
scripts,
|
||||
}: WorkspaceScriptsButtonProps): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const startScriptMutation = useMutation({
|
||||
mutationFn: async (scriptName: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
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 : `Failed to start ${scriptName}`,
|
||||
{ variant: "error" },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (scripts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.splitButton}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-scripts-button"
|
||||
style={({ hovered, pressed, open }) => [
|
||||
styles.splitButtonPrimary,
|
||||
(hovered || pressed || open) && styles.splitButtonPrimaryHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace scripts"
|
||||
>
|
||||
<View style={styles.splitButtonContent}>
|
||||
<Terminal size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.splitButtonText}>Scripts</Text>
|
||||
{hasAnyRunning ? (
|
||||
<View
|
||||
style={[
|
||||
styles.runningDot,
|
||||
{ backgroundColor: theme.colors.palette.green[500] },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
minWidth={200}
|
||||
maxWidth={280}
|
||||
testID="workspace-scripts-menu"
|
||||
>
|
||||
<View style={styles.scriptList}>
|
||||
{scripts.map((script) => {
|
||||
const isRunning = script.lifecycle === "running";
|
||||
const isLinkable = isRunning && !!script.url;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={script.scriptName}
|
||||
testID={`workspace-scripts-item-${script.scriptName}`}
|
||||
accessibilityRole={isLinkable ? "link" : undefined}
|
||||
accessibilityLabel={`${script.scriptName} script`}
|
||||
style={({ hovered }) => [
|
||||
styles.scriptRow,
|
||||
hovered && isLinkable && styles.scriptRowHovered,
|
||||
]}
|
||||
onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined}
|
||||
disabled={!isLinkable}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: isRunning
|
||||
? getScriptHealthColor(script.health, theme)
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.scriptName,
|
||||
{
|
||||
color: isRunning
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
{isRunning && script.url ? (
|
||||
<Text style={styles.scriptUrl} numberOfLines={1}>
|
||||
{script.url.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.spacer} />
|
||||
)}
|
||||
{isRunning ? (
|
||||
script.url && hovered ? (
|
||||
<View
|
||||
style={[
|
||||
styles.externalLinkOverlay,
|
||||
{
|
||||
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ExternalLink size={12} color={theme.colors.foreground} />
|
||||
</View>
|
||||
) : null
|
||||
) : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Run ${script.scriptName} script`}
|
||||
testID={`workspace-scripts-start-${script.scriptName}`}
|
||||
hitSlop={4}
|
||||
disabled={startScriptMutation.isPending}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
startScriptMutation.mutate(script.scriptName);
|
||||
}}
|
||||
style={styles.startButton}
|
||||
>
|
||||
{({ hovered: actionHovered }) =>
|
||||
startScriptMutation.isPending &&
|
||||
startScriptMutation.variables === script.scriptName ? (
|
||||
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<>
|
||||
<Play
|
||||
size={10}
|
||||
color={
|
||||
actionHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
fill="transparent"
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.startButtonLabel,
|
||||
{
|
||||
color: actionHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Run
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
splitButtonPrimary: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
splitButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
splitButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
runningDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
scriptList: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
scriptRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: 6,
|
||||
minHeight: 28,
|
||||
},
|
||||
scriptRowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
scriptName: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
flexShrink: 0,
|
||||
},
|
||||
scriptUrl: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 18,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
textAlign: "right",
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
externalLinkOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[3],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
startButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
startButtonLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -20,7 +20,7 @@ describe("workspace source of truth consumption", () => {
|
||||
status: "running",
|
||||
activityAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
diffStat: null,
|
||||
services: [],
|
||||
scripts: [],
|
||||
};
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace });
|
||||
|
||||
@@ -25,7 +25,7 @@ function createWorkspace(
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
services: input.services ?? [],
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("normalizeWorkspaceDescriptor", () => {
|
||||
it("normalizes workspace services and invalid activity timestamps", () => {
|
||||
const services = [
|
||||
it("normalizes workspace scripts and invalid activity timestamps", () => {
|
||||
const scripts = [
|
||||
{
|
||||
serviceName: "web",
|
||||
scriptName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
@@ -57,13 +57,13 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
status: "running",
|
||||
activityAt: "not-a-date",
|
||||
diffStat: null,
|
||||
services,
|
||||
scripts,
|
||||
});
|
||||
|
||||
expect(workspace.activityAt).toBeNull();
|
||||
expect(workspace.services).toEqual([
|
||||
expect(workspace.scripts).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
scriptName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
@@ -71,10 +71,10 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
health: "healthy",
|
||||
},
|
||||
]);
|
||||
expect(workspace.services).not.toBe(services);
|
||||
expect(workspace.scripts).not.toBe(scripts);
|
||||
});
|
||||
|
||||
it("defaults missing services to an empty array", () => {
|
||||
it("defaults missing scripts to an empty array", () => {
|
||||
const payload = {
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
@@ -87,30 +87,30 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
services: [],
|
||||
scripts: [],
|
||||
} as WorkspaceDescriptorPayload;
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload);
|
||||
|
||||
expect(workspace.services).toEqual([]);
|
||||
expect(workspace.scripts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaces", () => {
|
||||
it("preserves services on merged workspace entries", () => {
|
||||
it("preserves scripts on merged workspace entries", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession("test-server", null as unknown as DaemonClient);
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([["/repo/main", createWorkspace({ id: "/repo/main", services: [] })]]),
|
||||
new Map([["/repo/main", createWorkspace({ id: "/repo/main", scripts: [] })]]),
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [
|
||||
createWorkspace({
|
||||
id: "/repo/main",
|
||||
services: [
|
||||
scripts: [
|
||||
{
|
||||
serviceName: "web",
|
||||
scriptName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
@@ -121,9 +121,9 @@ describe("mergeWorkspaces", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([
|
||||
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
scriptName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
|
||||
@@ -124,7 +124,7 @@ export interface WorkspaceDescriptor {
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
activityAt: Date | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
services: WorkspaceDescriptorPayload["services"];
|
||||
scripts: WorkspaceDescriptorPayload["scripts"];
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -143,7 +143,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
services: (payload.services ?? []).map((s) => ({ ...s })),
|
||||
scripts: (payload.scripts ?? []).map((s) => ({ ...s })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -964,7 +964,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
if (existing === workspace) {
|
||||
continue;
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
next.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({ incoming: workspace, existing }),
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) {
|
||||
|
||||
@@ -258,6 +258,7 @@ const commonTheme = {
|
||||
spacing: {
|
||||
0: 0,
|
||||
1: 4,
|
||||
1.5: 6,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
|
||||
@@ -19,8 +19,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
services: [],
|
||||
hasRunningServices: false,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
services: [],
|
||||
hasRunningServices: false,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ function workspace(
|
||||
status: input.status ?? "running",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
services: input.services ?? [],
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function createWorkspace(
|
||||
status: input.status ?? "running",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
services: input.services ?? [],
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user