From 945e3bf4e0eb43e13a53a785cb39636481df9dac Mon Sep 17 00:00:00 2001
From: Crauzer <0xcrauzer@proton.me>
Date: Mon, 20 Apr 2026 11:19:15 +0200
Subject: [PATCH] fix(workshop): improve mod testing ux
---
.../patcher/components/PatcherStatusPill.tsx | 88 +++++++
src/modules/patcher/components/StatusBar.tsx | 15 --
src/modules/patcher/components/index.ts | 1 +
src/modules/workshop/api/index.ts | 1 +
.../workshop/api/useWorkshopTestState.ts | 46 ++++
.../workshop/components/ProjectCard.tsx | 220 +++++++++++++-----
.../workshop/components/ProjectHeader.tsx | 69 +++++-
src/routes/__root.tsx | 2 +
8 files changed, 357 insertions(+), 85 deletions(-)
create mode 100644 src/modules/patcher/components/PatcherStatusPill.tsx
create mode 100644 src/modules/workshop/api/useWorkshopTestState.ts
diff --git a/src/modules/patcher/components/PatcherStatusPill.tsx b/src/modules/patcher/components/PatcherStatusPill.tsx
new file mode 100644
index 0000000..9c92609
--- /dev/null
+++ b/src/modules/patcher/components/PatcherStatusPill.tsx
@@ -0,0 +1,88 @@
+import { Loader2, Square } from "lucide-react";
+import { useEffect, useRef } from "react";
+import { twMerge } from "tailwind-merge";
+
+import { IconButton, Kbd, Tooltip } from "@/components";
+import { usePatcherSessionStore } from "@/stores";
+
+import { usePatcherStatus, useStopPatcher } from "../api";
+
+export function PatcherStatusPill() {
+ const { data: status } = usePatcherStatus();
+ const testingProjects = usePatcherSessionStore((s) => s.testingProjects);
+ const clearTestingProjects = usePatcherSessionStore((s) => s.clearTestingProjects);
+ const stopPatcher = useStopPatcher();
+
+ const running = status?.running ?? false;
+ const building = status?.phase === "building";
+ const isIdle = !running && !building;
+
+ const wasActiveRef = useRef(false);
+ useEffect(() => {
+ if (!isIdle) {
+ wasActiveRef.current = true;
+ } else if (wasActiveRef.current && testingProjects.length > 0) {
+ clearTestingProjects();
+ wasActiveRef.current = false;
+ }
+ }, [isIdle, testingProjects, clearTestingProjects]);
+
+ if (isIdle) return null;
+
+ const testLabel =
+ testingProjects.length === 1
+ ? testingProjects[0].displayName
+ : testingProjects.length > 1
+ ? `${testingProjects.length} projects`
+ : null;
+
+ const label = building
+ ? testLabel
+ ? `Building ${testLabel}…`
+ : "Building overlay…"
+ : testLabel
+ ? `Testing ${testLabel}`
+ : "Patcher running";
+
+ const tone = building ? "accent" : "running";
+
+ return (
+
+ {building ? (
+
+ ) : (
+
+
+
+
+ )}
+ {label}
+ {!building && (
+
+ Stop patcher
+ >
+ }
+ >
+ }
+ variant="ghost"
+ size="xs"
+ onClick={() => stopPatcher.mutate()}
+ loading={stopPatcher.isPending}
+ aria-label="Stop patcher"
+ className="-mr-1.5 text-green-300 hover:bg-green-500/25 hover:text-green-200"
+ />
+
+ )}
+
+ );
+}
diff --git a/src/modules/patcher/components/StatusBar.tsx b/src/modules/patcher/components/StatusBar.tsx
index e7eecc5..0fa5cb1 100644
--- a/src/modules/patcher/components/StatusBar.tsx
+++ b/src/modules/patcher/components/StatusBar.tsx
@@ -1,5 +1,4 @@
import { Loader2 } from "lucide-react";
-import { useEffect, useRef } from "react";
import { Progress } from "@/components";
import type { OverlayProgress } from "@/lib/tauri";
@@ -26,22 +25,8 @@ export function StatusBar() {
useHotkeyEvents();
const testingProjects = usePatcherSessionStore((s) => s.testingProjects);
- const clearTestingProjects = usePatcherSessionStore((s) => s.clearTestingProjects);
const isBuilding = patcherStatus?.phase === "building";
- const isRunning = patcherStatus?.running ?? false;
- const isIdle = !isRunning && !isBuilding;
-
- const wasActiveRef = useRef(false);
-
- useEffect(() => {
- if (!isIdle) {
- wasActiveRef.current = true;
- } else if (wasActiveRef.current && testingProjects.length > 0) {
- clearTestingProjects();
- wasActiveRef.current = false;
- }
- }, [isIdle, testingProjects, clearTestingProjects]);
if (!isBuilding) return null;
diff --git a/src/modules/patcher/components/index.ts b/src/modules/patcher/components/index.ts
index 07e8732..8b281ce 100644
--- a/src/modules/patcher/components/index.ts
+++ b/src/modules/patcher/components/index.ts
@@ -1,2 +1,3 @@
+export * from "./PatcherStatusPill";
export * from "./PatcherUnsupported";
export * from "./StatusBar";
diff --git a/src/modules/workshop/api/index.ts b/src/modules/workshop/api/index.ts
index 98f95c1..5999ae0 100644
--- a/src/modules/workshop/api/index.ts
+++ b/src/modules/workshop/api/index.ts
@@ -22,3 +22,4 @@ export { useTestProjects } from "./useTestProject";
export { useValidateProject, validateProjectOptions } from "./useValidateProject";
export { useWorkshopProject, workshopProjectOptions } from "./useWorkshopProject";
export { useWorkshopProjects, workshopProjectsOptions } from "./useWorkshopProjects";
+export { useWorkshopTestState, type WorkshopTestState } from "./useWorkshopTestState";
diff --git a/src/modules/workshop/api/useWorkshopTestState.ts b/src/modules/workshop/api/useWorkshopTestState.ts
new file mode 100644
index 0000000..74fbb88
--- /dev/null
+++ b/src/modules/workshop/api/useWorkshopTestState.ts
@@ -0,0 +1,46 @@
+import { useMemo } from "react";
+
+import type { WorkshopProject } from "@/lib/tauri";
+import { usePatcherStatus } from "@/modules/patcher";
+import { usePatcherSessionStore } from "@/stores";
+
+export type WorkshopTestState =
+ | { kind: "idle" }
+ | { kind: "building-this" }
+ | { kind: "running-this" }
+ | { kind: "building-other"; otherLabel: string }
+ | { kind: "running-other"; otherLabel: string }
+ | { kind: "building-library" }
+ | { kind: "running-library" };
+
+export function useWorkshopTestState(project?: WorkshopProject): WorkshopTestState {
+ const { data: status } = usePatcherStatus();
+ const testingProjects = usePatcherSessionStore((s) => s.testingProjects);
+
+ return useMemo(() => {
+ const running = status?.running ?? false;
+ const building = status?.phase === "building";
+ const pendingTest = !running && !building && testingProjects.length > 0;
+
+ if (!running && !building && !pendingTest) return { kind: "idle" };
+
+ const inBuildPhase = building || pendingTest;
+
+ const isThis = project ? testingProjects.some((p) => p.path === project.path) : false;
+ if (isThis) {
+ return inBuildPhase ? { kind: "building-this" } : { kind: "running-this" };
+ }
+
+ if (testingProjects.length > 0) {
+ const otherLabel =
+ testingProjects.length === 1
+ ? testingProjects[0].displayName
+ : `${testingProjects.length} projects`;
+ return inBuildPhase
+ ? { kind: "building-other", otherLabel }
+ : { kind: "running-other", otherLabel };
+ }
+
+ return inBuildPhase ? { kind: "building-library" } : { kind: "running-library" };
+ }, [status, project, testingProjects]);
+}
diff --git a/src/modules/workshop/components/ProjectCard.tsx b/src/modules/workshop/components/ProjectCard.tsx
index 6e88995..0cc1148 100644
--- a/src/modules/workshop/components/ProjectCard.tsx
+++ b/src/modules/workshop/components/ProjectCard.tsx
@@ -1,20 +1,18 @@
import { invoke } from "@tauri-apps/api/core";
-import { EllipsisVertical, FolderOpen, Package, Pencil, Play, Trash2 } from "lucide-react";
-import { useMemo } from "react";
+import { EllipsisVertical, FolderOpen, Package, Pencil, Play, Trash2, X } from "lucide-react";
+import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
+import { match } from "ts-pattern";
-import { Button, Checkbox, IconButton, Menu } from "@/components";
+import { Button, Checkbox, IconButton, Menu, Tooltip } from "@/components";
import type { WorkshopProject } from "@/lib/tauri";
import { getTagLabel } from "@/modules/library";
-import { usePatcherStatus } from "@/modules/patcher";
-import {
- usePatcherSessionStore,
- useWorkshopDialogsStore,
- useWorkshopSelectionStore,
-} from "@/stores";
+import { useStopPatcher } from "@/modules/patcher";
+import { useWorkshopDialogsStore, useWorkshopSelectionStore } from "@/stores";
import { useProjectThumbnail } from "../api/useProjectThumbnail";
import { useTestProjects } from "../api/useTestProject";
+import { useWorkshopTestState } from "../api/useWorkshopTestState";
import type { ViewMode } from "./WorkshopToolbar";
interface ProjectCardProps {
@@ -29,21 +27,16 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
const selected = useWorkshopSelectionStore((s) => s.selectedPaths.has(project.path));
const toggle = useWorkshopSelectionStore((s) => s.toggle);
- const { data: patcherStatus } = usePatcherStatus();
- const isPatcherActive = patcherStatus?.running ?? false;
+ const testState = useWorkshopTestState(project);
+ const stopPatcher = useStopPatcher();
+ const testProjects = useTestProjects();
- const testingProjects = usePatcherSessionStore((s) => s.testingProjects);
- const isTesting = useMemo(
- () => testingProjects.some((p) => p.path === project.path),
- [testingProjects, project.path],
- );
+ const isPatcherActive = testState.kind !== "idle";
+ const isTestingThis = testState.kind === "building-this" || testState.kind === "running-this";
const openPackDialog = useWorkshopDialogsStore((s) => s.openPackDialog);
const openDeleteDialog = useWorkshopDialogsStore((s) => s.openDeleteDialog);
- const testProjects = useTestProjects();
- const isTestDisabled = isPatcherActive || testProjects.isPending;
-
function handleTest() {
testProjects.mutate(
{ projects: [{ path: project.path, displayName: project.displayName }] },
@@ -51,6 +44,10 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
);
}
+ function handleStop() {
+ stopPatcher.mutate();
+ }
+
async function handleOpenLocation() {
try {
await invoke("reveal_in_explorer", { path: project.path });
@@ -59,7 +56,37 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
}
}
- const listBorderClass = isTesting
+ const testButton = renderTestButton({
+ testState,
+ onTest: handleTest,
+ onStop: handleStop,
+ isStopping: stopPatcher.isPending,
+ isTesting: testProjects.isPending,
+ });
+
+ const testMenuItem = renderTestMenuItem({
+ testState,
+ onTest: handleTest,
+ onStop: handleStop,
+ });
+
+ const stopPill = (
+
+ );
+
+ const listBorderClass = isTestingThis
? "border-green-500/40"
: selected
? "border-accent-500/40"
@@ -71,14 +98,14 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
className={twMerge(
"group flex cursor-pointer items-center gap-4 rounded-lg border bg-surface-900 p-4 transition-[transform,box-shadow,background-color,border-color] duration-150 ease-out hover:-translate-y-px hover:border-surface-600 hover:shadow-md",
listBorderClass,
- isPatcherActive && !isTesting && "opacity-50",
+ isPatcherActive && !isTestingThis && "opacity-50",
)}
onClick={() => onEdit(project)}
>
e.stopPropagation()}>
toggle(project.path)}
disabled={isPatcherActive}
/>
@@ -110,22 +137,10 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
- {isTesting && (
-
- Testing
-
- )}
+ {isTestingThis && stopPill}
e.stopPropagation()}>
- }
- onClick={handleTest}
- disabled={isTestDisabled}
- >
- Test
-
+ {testButton}
} onClick={() => onEdit(project)}>
Edit Project
-
}
- onClick={handleTest}
- disabled={isTestDisabled}
- >
- Test
-
+ {testMenuItem}
}
onClick={() => openPackDialog(project)}
@@ -183,7 +192,7 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
);
}
- const gridBorderClass = isTesting
+ const gridBorderClass = isTestingThis
? "border-green-500/40"
: selected
? "border-accent-500/40"
@@ -194,7 +203,7 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
className={twMerge(
"group relative cursor-pointer rounded-xl border bg-surface-800 transition-[transform,box-shadow,background-color,border-color] duration-150 ease-out hover:-translate-y-px hover:border-surface-400 hover:shadow-md",
gridBorderClass,
- isPatcherActive && !isTesting && "opacity-50",
+ isPatcherActive && !isTestingThis && "opacity-50",
)}
onClick={() => onEdit(project)}
>
@@ -210,7 +219,7 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
>
toggle(project.path)}
disabled={isPatcherActive}
/>
@@ -240,11 +249,7 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
{project.authors.length > 0 ? project.authors[0].name : "Unknown"}
- {isTesting && (
-
- Testing
-
- )}
+ {isTestingThis && stopPill}
e.stopPropagation()}>
@@ -258,13 +263,7 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
} onClick={() => onEdit(project)}>
Edit Project
-
}
- onClick={handleTest}
- disabled={isPatcherActive}
- >
- Test
-
+ {testMenuItem}
}
onClick={() => openPackDialog(project)}
@@ -292,6 +291,111 @@ export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) {
);
}
+interface TestButtonArgs {
+ testState: ReturnType
;
+ onTest: () => void;
+ onStop: () => void;
+ isStopping: boolean;
+ isTesting: boolean;
+}
+
+function renderTestButton({
+ testState,
+ onTest,
+ onStop,
+ isStopping,
+ isTesting,
+}: TestButtonArgs): ReactNode {
+ return match(testState)
+ .with({ kind: "idle" }, () => (
+ }
+ onClick={onTest}
+ loading={isTesting}
+ >
+ Test
+
+ ))
+ .with({ kind: "building-this" }, () => (
+
+ ))
+ .with({ kind: "running-this" }, () => (
+
+ ))
+ .with({ kind: "building-other" }, { kind: "running-other" }, ({ otherLabel }) => (
+
+ }>
+ Test
+
+
+ ))
+ .with({ kind: "building-library" }, { kind: "running-library" }, () => (
+
+ }>
+ Test
+
+
+ ))
+ .exhaustive();
+}
+
+interface TestMenuItemArgs {
+ testState: ReturnType;
+ onTest: () => void;
+ onStop: () => void;
+}
+
+function renderTestMenuItem({ testState, onTest, onStop }: TestMenuItemArgs): ReactNode {
+ return match(testState)
+ .with({ kind: "idle" }, () => (
+ } onClick={onTest}>
+ Test
+
+ ))
+ .with({ kind: "building-this" }, () => (
+ } disabled>
+ Building…
+
+ ))
+ .with({ kind: "running-this" }, () => (
+ } onClick={onStop}>
+ Stop Test
+
+ ))
+ .with(
+ { kind: "building-other" },
+ { kind: "running-other" },
+ { kind: "building-library" },
+ { kind: "running-library" },
+ () => (
+ } disabled>
+ Test
+
+ ),
+ )
+ .exhaustive();
+}
+
function ProjectPills({
project,
max,
diff --git a/src/modules/workshop/components/ProjectHeader.tsx b/src/modules/workshop/components/ProjectHeader.tsx
index 8c35d46..7227e74 100644
--- a/src/modules/workshop/components/ProjectHeader.tsx
+++ b/src/modules/workshop/components/ProjectHeader.tsx
@@ -1,21 +1,74 @@
import { Link } from "@tanstack/react-router";
import { ArrowLeft, EllipsisVertical, FolderOpen, Package, Play, Trash2 } from "lucide-react";
+import { match } from "ts-pattern";
import { Button, IconButton, Menu, Tooltip } from "@/components";
import type { WorkshopProject } from "@/lib/tauri";
-import { usePatcherStatus } from "@/modules/patcher";
+import { useStopPatcher } from "@/modules/patcher";
import { useProjectActions } from "../api/useProjectActions";
+import { useWorkshopTestState } from "../api/useWorkshopTestState";
interface ProjectHeaderProps {
project: WorkshopProject;
}
export function ProjectHeader({ project }: ProjectHeaderProps) {
- const { data: patcherStatus } = usePatcherStatus();
- const isPatcherActive = patcherStatus?.running ?? false;
+ const testState = useWorkshopTestState(project);
+ const stopPatcher = useStopPatcher();
const actions = useProjectActions(project);
+ const testButton = match(testState)
+ .with({ kind: "idle" }, () => (
+ }
+ onClick={actions.handleTestProject}
+ >
+ Test
+
+ ))
+ .with({ kind: "building-this" }, () => (
+
+ ))
+ .with({ kind: "running-this" }, () => (
+
+ ))
+ .with({ kind: "building-other" }, { kind: "running-other" }, ({ otherLabel }) => (
+
+ }>
+ Test
+
+
+ ))
+ .with({ kind: "building-library" }, { kind: "running-library" }, () => (
+
+ }>
+ Test
+
+
+ ))
+ .exhaustive();
+
return (
@@ -39,15 +92,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
-
}
- onClick={actions.handleTestProject}
- disabled={isPatcherActive}
- >
- Test
-
+ {testButton}
);