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()}> - + {testButton}
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" }, () => ( + + )) + .with({ kind: "building-this" }, () => ( + + )) + .with({ kind: "running-this" }, () => ( + + )) + .with({ kind: "building-other" }, { kind: "running-other" }, ({ otherLabel }) => ( + + + + )) + .with({ kind: "building-library" }, { kind: "running-library" }, () => ( + + + + )) + .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" }, () => ( + + )) + .with({ kind: "building-this" }, () => ( + + )) + .with({ kind: "running-this" }, () => ( + + )) + .with({ kind: "building-other" }, { kind: "running-other" }, ({ otherLabel }) => ( + + + + )) + .with({ kind: "building-library" }, { kind: "running-library" }, () => ( + + + + )) + .exhaustive(); + return (
@@ -39,15 +92,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) {
- + {testButton}
);