diff --git a/.gitignore b/.gitignore index 172262d..28a727c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ target/ **/*.rs.bk *.pdb +# ts-rs default output (real bindings go to src/lib/bindings/) +src-tauri/bindings/ + # AI local settings .claude/ .mcp.json diff --git a/src-tauri/src/commands/patcher.rs b/src-tauri/src/commands/patcher.rs index 93e4a89..57da355 100644 --- a/src-tauri/src/commands/patcher.rs +++ b/src-tauri/src/commands/patcher.rs @@ -31,6 +31,12 @@ pub struct PatcherConfig { /// If not provided, defaults to 0 (equivalent to `--opts:none` in cslol-tools). #[ts(optional, type = "number")] pub flags: Option, + /// Absolute paths to workshop project directories to include in the overlay. + /// + /// These are loaded directly from disk via `FsModContent` and prepended to + /// the enabled mod list (highest priority). + #[ts(optional)] + pub workshop_projects: Option>, } /// Current status of the patcher. @@ -151,6 +157,13 @@ fn start_patcher_inner( let log_file = config.log_file.clone(); let timeout_ms = config.timeout_ms.unwrap_or(DEFAULT_HOOK_TIMEOUT_MS); let flags = config.flags.unwrap_or(0); + let workshop_paths: Vec = config + .workshop_projects + .unwrap_or_default() + .iter() + .map(PathBuf::from) + .collect(); + let settings_snapshot = settings.0.lock().mutex_err()?.clone(); tracing::info!( @@ -171,20 +184,21 @@ fn start_patcher_inner( let handle = thread::spawn(move || { // Phase 1: Build overlay (the slow part) - let overlay_root = match overlay::ensure_overlay(&library_clone, &settings_snapshot) { - Ok(root) => root, - Err(e) => { - tracing::error!(error = ?e, "Overlay build failed"); - let error_response: AppErrorResponse = e.into(); - let _ = library_clone - .app_handle() - .emit("patcher-error", &error_response); - if let Ok(mut s) = state_arc.lock() { - s.phase = PatcherPhase::Idle; + let overlay_root = + match overlay::ensure_overlay(&library_clone, &settings_snapshot, &workshop_paths) { + Ok(root) => root, + Err(e) => { + tracing::error!(error = ?e, "Overlay build failed"); + let error_response: AppErrorResponse = e.into(); + let _ = library_clone + .app_handle() + .emit("patcher-error", &error_response); + if let Ok(mut s) = state_arc.lock() { + s.phase = PatcherPhase::Idle; + } + return; } - return; - } - }; + }; // Check stop flag between build and patcher loop if stop_flag.load(Ordering::SeqCst) { diff --git a/src-tauri/src/overlay/mod.rs b/src-tauri/src/overlay/mod.rs index 667a14e..f99fe40 100644 --- a/src-tauri/src/overlay/mod.rs +++ b/src-tauri/src/overlay/mod.rs @@ -33,7 +33,14 @@ pub struct OverlayProgress { /// Ensure the overlay exists and is up-to-date for the current enabled mod set. /// /// Returns the overlay root directory (the prefix passed to the legacy patcher). -pub fn ensure_overlay(library: &ModLibrary, settings: &Settings) -> AppResult { +/// +/// Workshop project paths (if any) are loaded via `FsModContent` and prepended +/// to the enabled mod list so they take highest priority. +pub fn ensure_overlay( + library: &ModLibrary, + settings: &Settings, + workshop_project_paths: &[PathBuf], +) -> AppResult { let storage_dir = library.storage_dir(settings)?; let game_dir = resolve_game_dir(settings)?; @@ -101,7 +108,24 @@ pub fn ensure_overlay(library: &ModLibrary, settings: &Settings) -> AppResult = { xl: "text-2xl", }; +const iconSlotSizeClasses: Record = { + xs: "h-3 w-3", + sm: "h-4 w-4", + md: "h-4 w-4", + lg: "h-5 w-5", + xl: "h-5 w-5", +}; + +function IconSlot({ children, size }: { children: ReactNode; size: ButtonSize }) { + return ( + + {children} + + ); +} + export const Button = forwardRef( ( { @@ -107,12 +128,12 @@ export const Button = forwardRef( {children && {children}} )) - .with([false, true], () => icon) + .with([false, true], () => {icon}) .with([false, false], () => ( <> - {leftIcon} + {leftIcon && {leftIcon}} {children} - {rightIcon} + {rightIcon && {rightIcon}} )) .exhaustive(); diff --git a/src/lib/bindings/PatcherConfig.ts b/src/lib/bindings/PatcherConfig.ts index 67e83a7..736b9dc 100644 --- a/src/lib/bindings/PatcherConfig.ts +++ b/src/lib/bindings/PatcherConfig.ts @@ -18,4 +18,11 @@ export type PatcherConfig = { * If not provided, defaults to 0 (equivalent to `--opts:none` in cslol-tools). */ flags?: number; + /** + * Absolute paths to workshop project directories to include in the overlay. + * + * These are loaded directly from disk via `FsModContent` and prepended to + * the enabled mod list (highest priority). + */ + workshopProjects?: Array; }; diff --git a/src/modules/patcher/components/StatusBar.tsx b/src/modules/patcher/components/StatusBar.tsx index 14bbe85..e7c53cf 100644 --- a/src/modules/patcher/components/StatusBar.tsx +++ b/src/modules/patcher/components/StatusBar.tsx @@ -1,7 +1,9 @@ +import { useEffect, useRef } from "react"; import { LuLoader, LuSquare } from "react-icons/lu"; import { Button, Progress } from "@/components"; import type { OverlayProgress } from "@/lib/tauri"; +import { usePatcherSessionStore } from "@/stores"; import { useOverlayProgress, usePatcherError, usePatcherStatus, useStopPatcher } from "../api"; @@ -23,11 +25,32 @@ export function StatusBar() { const stopPatcher = useStopPatcher(); usePatcherError(); + 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]); - // Hide when idle - if (!isRunning && !isBuilding) return null; + if (isIdle) return null; + + const testLabel = + testingProjects.length === 1 + ? `Testing ${testingProjects[0].displayName}` + : testingProjects.length > 1 + ? `Testing ${testingProjects.length} projects` + : null; // Building overlay — show full progress if (isBuilding) { @@ -48,6 +71,11 @@ export function StatusBar() {
Building Overlay + {testLabel && ( + + {testLabel} + + )} {label}
{counter && ( @@ -72,7 +100,7 @@ export function StatusBar() { return (
- Patcher running + {testLabel ?? "Patcher running"}
-
diff --git a/src/modules/workshop/components/ImportFantomeDialog.tsx b/src/modules/workshop/components/ImportFantomeDialog.tsx index ee63840..8732ecd 100644 --- a/src/modules/workshop/components/ImportFantomeDialog.tsx +++ b/src/modules/workshop/components/ImportFantomeDialog.tsx @@ -3,7 +3,10 @@ import { z } from "zod"; import { Button, Dialog } from "@/components"; import { useAppForm } from "@/lib/form"; -import type { FantomeImportProgress, FantomePeekResult, ImportFantomeArgs } from "@/lib/tauri"; +import { useWorkshopDialogsStore } from "@/stores"; + +import { useFantomeImportProgress } from "../api/useFantomeImportProgress"; +import { useImportFromFantome } from "../api/useImportFromFantome"; const importSchema = z.object({ name: z @@ -17,26 +20,17 @@ const importSchema = z.object({ displayName: z.string().min(1, "Display name is required"), }); -interface ImportFantomeDialogProps { - open: boolean; - filePath: string | null; - peekResult: FantomePeekResult | null; - progress: FantomeImportProgress | null; - onClose: () => void; - onSubmit: (args: ImportFantomeArgs) => void; - isPending: boolean; -} +export function ImportFantomeDialog() { + const fantomeImport = useWorkshopDialogsStore((s) => s.fantomeImport); + const closeDialog = useWorkshopDialogsStore((s) => s.closeFantomeImportDialog); + const importFromFantome = useImportFromFantome(); + const progress = useFantomeImportProgress(); -export function ImportFantomeDialog({ - open, - filePath, - peekResult, - progress, - onClose, - onSubmit, - isPending, -}: ImportFantomeDialogProps) { - const isImporting = isPending || (progress !== null && progress.stage !== "complete"); + const open = fantomeImport !== null; + const peekResult = fantomeImport?.peekResult ?? null; + const filePath = fantomeImport?.filePath ?? null; + const isImporting = + importFromFantome.isPending || (progress !== null && progress.stage !== "complete"); const form = useAppForm({ defaultValues: { @@ -48,11 +42,20 @@ export function ImportFantomeDialog({ }, onSubmit: ({ value }) => { if (!filePath) return; - onSubmit({ - filePath, - name: value.name, - displayName: value.displayName, - }); + importFromFantome.mutate( + { + filePath, + name: value.name, + displayName: value.displayName, + }, + { + onSuccess: () => { + form.reset(); + closeDialog(); + }, + onError: (err) => console.error("Failed to import fantome:", err.message), + }, + ); }, }); @@ -68,7 +71,7 @@ export function ImportFantomeDialog({ function handleClose() { if (isImporting) return; form.reset(); - onClose(); + closeDialog(); } return ( diff --git a/src/modules/workshop/components/ImportGitRepoDialog.tsx b/src/modules/workshop/components/ImportGitRepoDialog.tsx index 6a2c91e..b09b308 100644 --- a/src/modules/workshop/components/ImportGitRepoDialog.tsx +++ b/src/modules/workshop/components/ImportGitRepoDialog.tsx @@ -2,7 +2,10 @@ import { z } from "zod"; import { Button, Dialog } from "@/components"; import { useAppForm } from "@/lib/form"; -import type { GitImportProgress, ImportGitRepoArgs } from "@/lib/tauri"; +import { useWorkshopDialogsStore } from "@/stores"; + +import { useGitImportProgress } from "../api/useGitImportProgress"; +import { useImportFromGitRepo } from "../api/useImportFromGitRepo"; const importSchema = z.object({ url: z @@ -15,22 +18,14 @@ const importSchema = z.object({ branch: z.string(), }); -interface ImportGitRepoDialogProps { - open: boolean; - progress: GitImportProgress | null; - onClose: () => void; - onSubmit: (args: ImportGitRepoArgs) => void; - isPending: boolean; -} +export function ImportGitRepoDialog() { + const open = useWorkshopDialogsStore((s) => s.gitImportOpen); + const closeDialog = useWorkshopDialogsStore((s) => s.closeGitImportDialog); + const importFromGitRepo = useImportFromGitRepo(); + const progress = useGitImportProgress(); -export function ImportGitRepoDialog({ - open, - progress, - onClose, - onSubmit, - isPending, -}: ImportGitRepoDialogProps) { - const isImporting = isPending || (progress !== null && progress.stage !== "complete"); + const isImporting = + importFromGitRepo.isPending || (progress !== null && progress.stage !== "complete"); const form = useAppForm({ defaultValues: { @@ -41,17 +36,26 @@ export function ImportGitRepoDialog({ onChange: importSchema, }, onSubmit: ({ value }) => { - onSubmit({ - url: value.url, - branch: value.branch || undefined, - }); + importFromGitRepo.mutate( + { + url: value.url, + branch: value.branch || undefined, + }, + { + onSuccess: () => { + form.reset(); + closeDialog(); + }, + onError: (err) => console.error("Failed to import from git repo:", err.message), + }, + ); }, }); function handleClose() { if (isImporting) return; form.reset(); - onClose(); + closeDialog(); } return ( diff --git a/src/modules/workshop/components/NewProjectDialog.tsx b/src/modules/workshop/components/NewProjectDialog.tsx index fde9287..00a23df 100644 --- a/src/modules/workshop/components/NewProjectDialog.tsx +++ b/src/modules/workshop/components/NewProjectDialog.tsx @@ -2,9 +2,10 @@ import { z } from "zod"; import { Button, Dialog } from "@/components"; import { useAppForm } from "@/lib/form"; -import type { CreateProjectArgs } from "@/lib/tauri"; +import { useWorkshopDialogsStore } from "@/stores"; + +import { useCreateProject } from "../api/useCreateProject"; -// Validation schema for the project form const projectSchema = z.object({ name: z .string() @@ -19,13 +20,6 @@ const projectSchema = z.object({ authorName: z.string(), }); -interface NewProjectDialogProps { - open: boolean; - onClose: () => void; - onSubmit: (args: CreateProjectArgs) => void; - isPending?: boolean; -} - function generateDisplayName(slug: string) { return slug .split("-") @@ -33,7 +27,11 @@ function generateDisplayName(slug: string) { .join(" "); } -export function NewProjectDialog({ open, onClose, onSubmit, isPending }: NewProjectDialogProps) { +export function NewProjectDialog() { + const open = useWorkshopDialogsStore((s) => s.newProjectOpen); + const closeDialog = useWorkshopDialogsStore((s) => s.closeNewProjectDialog); + const createProject = useCreateProject(); + const form = useAppForm({ defaultValues: { name: "", @@ -45,18 +43,27 @@ export function NewProjectDialog({ open, onClose, onSubmit, isPending }: NewProj onChange: projectSchema, }, onSubmit: ({ value }) => { - onSubmit({ - name: value.name, - displayName: value.displayName || generateDisplayName(value.name), - description: value.description, - authors: value.authorName ? [value.authorName] : [], - }); + createProject.mutate( + { + name: value.name, + displayName: value.displayName || generateDisplayName(value.name), + description: value.description, + authors: value.authorName ? [value.authorName] : [], + }, + { + onSuccess: () => { + form.reset(); + closeDialog(); + }, + onError: (err) => console.error("Failed to create project:", err.message), + }, + ); }, }); function handleClose() { form.reset(); - onClose(); + closeDialog(); } return ( @@ -132,7 +139,7 @@ export function NewProjectDialog({ open, onClose, onSubmit, isPending }: NewProj {({ canSubmit, isValid }) => ( )} diff --git a/src/modules/workshop/components/ProjectCard.tsx b/src/modules/workshop/components/ProjectCard.tsx index 1d331b5..d299f5f 100644 --- a/src/modules/workshop/components/ProjectCard.tsx +++ b/src/modules/workshop/components/ProjectCard.tsx @@ -1,22 +1,61 @@ import { invoke } from "@tauri-apps/api/core"; -import { LuEllipsisVertical, LuFolderOpen, LuPackage, LuPencil, LuTrash2 } from "react-icons/lu"; +import { useMemo } from "react"; +import { + LuChevronRight, + LuEllipsisVertical, + LuFolderOpen, + LuPackage, + LuPencil, + LuPlay, + LuTrash2, +} from "react-icons/lu"; -import { Button, IconButton, Menu } from "@/components"; +import { Button, Checkbox, IconButton, Menu } from "@/components"; import type { WorkshopProject } from "@/lib/tauri"; +import { usePatcherStatus } from "@/modules/patcher"; +import { + usePatcherSessionStore, + useWorkshopDialogsStore, + useWorkshopSelectionStore, +} from "@/stores"; import { useProjectThumbnail } from "../api/useProjectThumbnail"; +import { useTestProjects } from "../api/useTestProject"; +import type { ViewMode } from "./WorkshopToolbar"; interface ProjectCardProps { project: WorkshopProject; - viewMode: "grid" | "list"; + viewMode: ViewMode; onEdit: (project: WorkshopProject) => void; - onPack: (project: WorkshopProject) => void; - onDelete: (project: WorkshopProject) => void; } -export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: ProjectCardProps) { +export function ProjectCard({ project, viewMode, onEdit }: ProjectCardProps) { const { data: thumbnailUrl } = useProjectThumbnail(project.path, project.thumbnailPath); + 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 testingProjects = usePatcherSessionStore((s) => s.testingProjects); + const isTesting = useMemo( + () => testingProjects.some((p) => p.path === project.path), + [testingProjects, project.path], + ); + + const openPackDialog = useWorkshopDialogsStore((s) => s.openPackDialog); + const openDeleteDialog = useWorkshopDialogsStore((s) => s.openDeleteDialog); + + const testProjects = useTestProjects(); + + function handleTest() { + testProjects.mutate( + { projects: [{ path: project.path, displayName: project.displayName }] }, + { onError: (err) => console.error("Failed to test project:", err.message) }, + ); + } + async function handleOpenLocation() { try { await invoke("reveal_in_explorer", { path: project.path }); @@ -25,20 +64,24 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro } } - function handleCardClick(e: React.MouseEvent) { - if ((e.target as HTMLElement).closest("[data-no-click]")) { - return; - } - onEdit(project); - } + const listBorderClass = isTesting + ? "border-green-500/40" + : selected + ? "border-brand-500/40" + : "border-surface-700"; if (viewMode === "list") { return (
- {/* Thumbnail */} + toggle(project.path)} + disabled={isPatcherActive} + /> +
{thumbnailUrl ? ( @@ -49,21 +92,40 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro )}
- {/* Info */}
-

{project.displayName}

+

onEdit(project)} + > + {project.displayName} + +

v{project.version} • {project.authors.map((a) => a.name).join(", ") || "Unknown author"}

- {/* Actions */} -
e.stopPropagation()}> + {isTesting && ( + + Testing + + )} + +
+ @@ -86,9 +148,16 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro > Edit Project + } + onClick={handleTest} + disabled={isPatcherActive} + > + Test + } - onClick={() => onPack(project)} + onClick={() => openPackDialog(project)} > Pack @@ -102,7 +171,7 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro } variant="danger" - onClick={() => onDelete(project)} + onClick={() => openDeleteDialog(project)} > Delete @@ -110,32 +179,76 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro + } + variant="ghost" + size="sm" + onClick={() => onEdit(project)} + />
); } - // Grid view + const gridBorderClass = isTesting + ? "border-green-500/40" + : selected + ? "border-brand-500/40" + : "border-surface-600"; + return (
- {/* Menu in top-right corner */}
e.stopPropagation()} + className={`absolute top-0 left-0 z-10 p-2 ${isPatcherActive ? "" : "cursor-pointer"}`} + onClick={(e) => { + if (!isPatcherActive && e.target === e.currentTarget) toggle(project.path); + }} > + toggle(project.path)} + disabled={isPatcherActive} + /> +
+ +
+ {thumbnailUrl ? ( + + ) : ( + + {project.displayName.charAt(0).toUpperCase()} + + )} +
+ +
+
+

onEdit(project)} + > + {project.displayName} + +

+
+ v{project.version} + + + {project.authors.length > 0 ? project.authors[0].name : "Unknown"} + + {isTesting && ( + + Testing + + )} +
+
} - variant="ghost" - size="sm" - /> - } + render={} variant="ghost" size="md" compact />} /> @@ -143,7 +256,17 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro } onClick={() => onEdit(project)}> Edit Project - } onClick={() => onPack(project)}> + } + onClick={handleTest} + disabled={isPatcherActive} + > + Test + + } + onClick={() => openPackDialog(project)} + > Pack } onClick={handleOpenLocation}> @@ -153,7 +276,7 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro } variant="danger" - onClick={() => onDelete(project)} + onClick={() => openDeleteDialog(project)} > Delete @@ -162,31 +285,6 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro
- - {/* Thumbnail */} -
- {thumbnailUrl ? ( - - ) : ( - - {project.displayName.charAt(0).toUpperCase()} - - )} -
- - {/* Content */} -
-

- {project.displayName} -

-
- v{project.version} - - - {project.authors.length > 0 ? project.authors[0].name : "Unknown"} - -
-
); } diff --git a/src/modules/workshop/components/ProjectGrid.tsx b/src/modules/workshop/components/ProjectGrid.tsx index f0aa43f..181681e 100644 --- a/src/modules/workshop/components/ProjectGrid.tsx +++ b/src/modules/workshop/components/ProjectGrid.tsx @@ -1,17 +1,16 @@ import type { WorkshopProject } from "@/lib/tauri"; +import { useWorkshopViewStore } from "@/stores"; import { ProjectCard } from "./ProjectCard"; -import type { ViewMode } from "./WorkshopToolbar"; interface ProjectGridProps { projects: WorkshopProject[]; - viewMode: ViewMode; onEdit: (project: WorkshopProject) => void; - onPack: (project: WorkshopProject) => void; - onDelete: (project: WorkshopProject) => void; } -export function ProjectGrid({ projects, viewMode, onEdit, onPack, onDelete }: ProjectGridProps) { +export function ProjectGrid({ projects, onEdit }: ProjectGridProps) { + const viewMode = useWorkshopViewStore((s) => s.viewMode); + return (
{projects.map((project) => ( - + ))}
); diff --git a/src/modules/workshop/components/ProjectHeader.tsx b/src/modules/workshop/components/ProjectHeader.tsx index 4459627..3947d84 100644 --- a/src/modules/workshop/components/ProjectHeader.tsx +++ b/src/modules/workshop/components/ProjectHeader.tsx @@ -1,17 +1,28 @@ import { Link } from "@tanstack/react-router"; -import { LuArrowLeft, LuEllipsisVertical, LuFolderOpen, LuPackage, LuTrash2 } from "react-icons/lu"; +import { + LuArrowLeft, + LuEllipsisVertical, + LuFolderOpen, + LuPackage, + LuPlay, + LuTrash2, +} from "react-icons/lu"; import { Button, IconButton, Menu } from "@/components"; import type { WorkshopProject } from "@/lib/tauri"; +import { usePatcherStatus } from "@/modules/patcher"; + +import { useProjectActions } from "../api/useProjectActions"; interface ProjectHeaderProps { project: WorkshopProject; - onPack: () => void; - onDelete: () => void; - onOpenLocation: () => void; } -export function ProjectHeader({ project, onPack, onDelete, onOpenLocation }: ProjectHeaderProps) { +export function ProjectHeader({ project }: ProjectHeaderProps) { + const { data: patcherStatus } = usePatcherStatus(); + const isPatcherActive = patcherStatus?.running ?? false; + const actions = useProjectActions(project); + return (
@@ -33,11 +44,20 @@ export function ProjectHeader({ project, onPack, onDelete, onOpenLocation }: Pro
+ @@ -54,14 +74,17 @@ export function ProjectHeader({ project, onPack, onDelete, onOpenLocation }: Pro - } onClick={onOpenLocation}> + } + onClick={actions.handleOpenLocation} + > Open Location } variant="danger" - onClick={onDelete} + onClick={actions.handleOpenDeleteDialog} > Delete diff --git a/src/modules/workshop/components/SelectionActionBar.tsx b/src/modules/workshop/components/SelectionActionBar.tsx new file mode 100644 index 0000000..c013552 --- /dev/null +++ b/src/modules/workshop/components/SelectionActionBar.tsx @@ -0,0 +1,81 @@ +import { LuPlay, LuX } from "react-icons/lu"; + +import { Button, Checkbox, IconButton } from "@/components"; +import { usePatcherStatus } from "@/modules/patcher"; +import { useWorkshopSelectionStore } from "@/stores"; + +import { useFilteredProjects } from "../api/useFilteredProjects"; +import { useTestProjects } from "../api/useTestProject"; + +export function SelectionActionBar() { + const selectedPaths = useWorkshopSelectionStore((s) => s.selectedPaths); + const selectAll = useWorkshopSelectionStore((s) => s.selectAll); + const clear = useWorkshopSelectionStore((s) => s.clear); + + const { data: patcherStatus } = usePatcherStatus(); + const isPatcherActive = patcherStatus?.running ?? false; + + const filteredProjects = useFilteredProjects(); + const testProjects = useTestProjects(); + + const selectedCount = selectedPaths.size; + const totalCount = filteredProjects.length; + + if (isPatcherActive || selectedCount === 0) return null; + + const allSelected = selectedCount === totalCount; + const indeterminate = selectedCount > 0 && !allSelected; + + function handleTest() { + const selected = filteredProjects.filter((p) => selectedPaths.has(p.path)); + if (selected.length === 0) return; + testProjects.mutate( + { projects: selected.map((p) => ({ path: p.path, displayName: p.displayName })) }, + { + onSuccess: () => clear(), + onError: (err) => console.error("Failed to test projects:", err.message), + }, + ); + } + + return ( +
+ { + if (allSelected) { + clear(); + } else { + selectAll(filteredProjects.map((p) => p.path)); + } + }} + aria-label="Toggle select all projects" + /> + + {selectedCount} of {totalCount} selected + + + } + variant="ghost" + size="md" + onClick={clear} + aria-label="Clear selection" + /> + +
+ + +
+ ); +} diff --git a/src/modules/workshop/components/WorkshopToolbar.tsx b/src/modules/workshop/components/WorkshopToolbar.tsx index 7aaaf82..9ff755a 100644 --- a/src/modules/workshop/components/WorkshopToolbar.tsx +++ b/src/modules/workshop/components/WorkshopToolbar.tsx @@ -1,3 +1,4 @@ +import { open } from "@tauri-apps/plugin-dialog"; import { LuChevronDown, LuDownload, @@ -8,69 +9,106 @@ import { LuPackage, LuPlus, LuSearch, + LuSquareCheckBig, } from "react-icons/lu"; import { Button, IconButton, Menu } from "@/components"; +import { usePatcherStatus } from "@/modules/patcher"; +import { useWorkshopDialogsStore, useWorkshopSelectionStore, useWorkshopViewStore } from "@/stores"; + +import { useFilteredProjects } from "../api/useFilteredProjects"; +import { useImportFromModpkg } from "../api/useImportFromModpkg"; +import { usePeekFantome } from "../api/usePeekFantome"; export type ViewMode = "grid" | "list"; -interface WorkshopToolbarProps { - searchQuery: string; - onSearchChange: (query: string) => void; - viewMode: ViewMode; - onViewModeChange: (mode: ViewMode) => void; - onImportModpkg: () => void; - onImportFantome: () => void; - onImportGitRepo: () => void; - onNewProject: () => void; - isImporting?: boolean; -} +export function WorkshopToolbar() { + const searchQuery = useWorkshopViewStore((s) => s.searchQuery); + const setSearchQuery = useWorkshopViewStore((s) => s.setSearchQuery); + const viewMode = useWorkshopViewStore((s) => s.viewMode); + const setViewMode = useWorkshopViewStore((s) => s.setViewMode); + + const selectAll = useWorkshopSelectionStore((s) => s.selectAll); + const filteredProjects = useFilteredProjects(); + + const { data: patcherStatus } = usePatcherStatus(); + const isPatcherActive = patcherStatus?.running ?? false; + + const openNewProjectDialog = useWorkshopDialogsStore((s) => s.openNewProjectDialog); + const openFantomeImportDialog = useWorkshopDialogsStore((s) => s.openFantomeImportDialog); + const openGitImportDialog = useWorkshopDialogsStore((s) => s.openGitImportDialog); + + const importFromModpkg = useImportFromModpkg(); + const peekFantome = usePeekFantome(); + + const isImporting = importFromModpkg.isPending || peekFantome.isPending; + + async function handleImportModpkg() { + const file = await open({ + multiple: false, + filters: [{ name: "Mod Package", extensions: ["modpkg"] }], + }); + if (file) { + importFromModpkg.mutate(file, { + onError: (err) => console.error("Failed to import modpkg:", err.message), + }); + } + } + + async function handleImportFantome() { + const file = await open({ + multiple: false, + filters: [{ name: "Fantome Archive", extensions: ["fantome", "zip"] }], + }); + if (!file) return; + + peekFantome.mutate(file, { + onSuccess: (result) => openFantomeImportDialog(result, file), + onError: (err) => console.error("Failed to peek fantome:", err.message), + }); + } -export function WorkshopToolbar({ - searchQuery, - onSearchChange, - viewMode, - onViewModeChange, - onImportModpkg, - onImportFantome, - onImportGitRepo, - onNewProject, - isImporting, -}: WorkshopToolbarProps) { return (
- {/* Search */}
onSearchChange(e.target.value)} + onChange={(e) => setSearchQuery(e.target.value)} className="w-full rounded-lg border border-surface-600 bg-surface-800 py-2 pr-4 pl-10 text-surface-100 placeholder:text-surface-500 focus:border-transparent focus:ring-2 focus:ring-brand-500 focus:outline-none" />
- {/* View toggle */}
} variant={viewMode === "grid" ? "default" : "ghost"} size="sm" - onClick={() => onViewModeChange("grid")} + onClick={() => setViewMode("grid")} /> } variant={viewMode === "list" ? "default" : "ghost"} size="sm" - onClick={() => onViewModeChange("list")} + onClick={() => setViewMode("list")} />
- {/* Actions */} + {!isPatcherActive && ( + } + variant="ghost" + size="sm" + onClick={() => selectAll(filteredProjects.map((p) => p.path))} + aria-label="Select all projects" + /> + )} + - } onClick={onImportFantome}> + } onClick={handleImportFantome}> From Fantome - } onClick={onImportModpkg}> + } onClick={handleImportModpkg}> From Modpkg - } onClick={onImportGitRepo}> + } onClick={openGitImportDialog}> From Git Repository @@ -104,7 +142,7 @@ export function WorkshopToolbar({
- - - + + ); } diff --git a/src/routes/workshop/index.tsx b/src/routes/workshop/index.tsx index fc75b37..e985bd9 100644 --- a/src/routes/workshop/index.tsx +++ b/src/routes/workshop/index.tsx @@ -1,16 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { open } from "@tauri-apps/plugin-dialog"; -import { useState } from "react"; -import type { - CreateProjectArgs, - FantomePeekResult, - ImportFantomeArgs, - ImportGitRepoArgs, - PackResult, - WorkshopProject, -} from "@/lib/tauri"; -import type { ViewMode } from "@/modules/workshop"; +import type { WorkshopProject } from "@/lib/tauri"; import { DeleteConfirmDialog, ErrorState, @@ -22,19 +12,12 @@ import { NoSearchResultsState, PackDialog, ProjectGrid, - useCreateProject, - useDeleteProject, - useFantomeImportProgress, - useGitImportProgress, - useImportFromFantome, - useImportFromGitRepo, - useImportFromModpkg, - usePackProject, - usePeekFantome, - useValidateProject, + SelectionActionBar, + useFilteredProjects, useWorkshopProjects, WorkshopToolbar, } from "@/modules/workshop"; +import { useWorkshopViewStore } from "@/stores"; export const Route = createFileRoute("/workshop/")({ component: WorkshopIndex, @@ -42,241 +25,34 @@ export const Route = createFileRoute("/workshop/")({ function WorkshopIndex() { const navigate = useNavigate(); - const [searchQuery, setSearchQuery] = useState(""); - const [viewMode, setViewMode] = useState("grid"); - - // Dialog state - const [newProjectOpen, setNewProjectOpen] = useState(false); - const [packDialogOpen, setPackDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [selectedProject, setSelectedProject] = useState(null); - const [packResult, setPackResult] = useState(null); - - // Fantome import state - const [fantomeDialogOpen, setFantomeDialogOpen] = useState(false); - const [fantomePeek, setFantomePeek] = useState(null); - const [fantomeFilePath, setFantomeFilePath] = useState(null); - - // Git repo import state - const [gitRepoDialogOpen, setGitRepoDialogOpen] = useState(false); - - // API hooks - const { data: projects = [], isLoading, error } = useWorkshopProjects(); - const createProject = useCreateProject(); - const deleteProject = useDeleteProject(); - const packProject = usePackProject(); - const importFromModpkg = useImportFromModpkg(); - const peekFantome = usePeekFantome(); - const importFromFantome = useImportFromFantome(); - const fantomeProgress = useFantomeImportProgress(); - const importFromGitRepo = useImportFromGitRepo(); - const gitImportProgress = useGitImportProgress(); - - const { data: validation, isLoading: validationLoading } = useValidateProject( - selectedProject?.path ?? "", - packDialogOpen, - ); - - const filteredProjects = projects.filter( - (project) => - project.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || - project.name.toLowerCase().includes(searchQuery.toLowerCase()), - ); - - function handleCreateProject(args: CreateProjectArgs) { - createProject.mutate(args, { - onSuccess: () => setNewProjectOpen(false), - onError: (err) => console.error("Failed to create project:", err.message), - }); - } + const { isLoading, error } = useWorkshopProjects(); + const searchQuery = useWorkshopViewStore((s) => s.searchQuery); + const filteredProjects = useFilteredProjects(); function handleEditProject(project: WorkshopProject) { navigate({ to: "/workshop/$projectName", params: { projectName: project.name } }); } - function handleOpenPackDialog(project: WorkshopProject) { - setSelectedProject(project); - setPackResult(null); - setPackDialogOpen(true); - } - - function handlePack(format: "modpkg" | "fantome") { - if (!selectedProject) return; - packProject.mutate( - { projectPath: selectedProject.path, format }, - { - onSuccess: setPackResult, - onError: (err) => console.error("Failed to pack project:", err.message), - }, - ); - } - - function handleClosePackDialog() { - setPackDialogOpen(false); - setSelectedProject(null); - setPackResult(null); - } - - function handleOpenDeleteDialog(project: WorkshopProject) { - setSelectedProject(project); - setDeleteDialogOpen(true); - } - - function handleDeleteProject() { - if (!selectedProject) return; - deleteProject.mutate(selectedProject.path, { - onSuccess: () => { - setDeleteDialogOpen(false); - setSelectedProject(null); - }, - onError: (err) => console.error("Failed to delete project:", err.message), - }); - } - - function handleCloseDeleteDialog() { - setDeleteDialogOpen(false); - setSelectedProject(null); - } - - async function handleImportModpkg() { - const file = await open({ - multiple: false, - filters: [{ name: "Mod Package", extensions: ["modpkg"] }], - }); - if (file) { - importFromModpkg.mutate(file, { - onError: (err) => console.error("Failed to import modpkg:", err.message), - }); - } - } - - async function handleImportFantome() { - const file = await open({ - multiple: false, - filters: [{ name: "Fantome Archive", extensions: ["fantome", "zip"] }], - }); - if (!file) return; - - peekFantome.mutate(file, { - onSuccess: (result) => { - setFantomePeek(result); - setFantomeFilePath(file); - setFantomeDialogOpen(true); - }, - onError: (err) => console.error("Failed to peek fantome:", err.message), - }); - } - - function handleImportFantomeSubmit(args: ImportFantomeArgs) { - importFromFantome.mutate(args, { - onSuccess: () => { - setFantomeDialogOpen(false); - setFantomePeek(null); - setFantomeFilePath(null); - }, - onError: (err) => console.error("Failed to import fantome:", err.message), - }); - } - - function handleCloseFantomeDialog() { - setFantomeDialogOpen(false); - setFantomePeek(null); - setFantomeFilePath(null); - } - - function handleImportGitRepoSubmit(args: ImportGitRepoArgs) { - importFromGitRepo.mutate(args, { - onSuccess: () => setGitRepoDialogOpen(false), - onError: (err) => console.error("Failed to import from git repo:", err.message), - }); - } - function renderContent() { if (isLoading) return ; if (error) return ; if (filteredProjects.length === 0) { if (searchQuery) return ; - return ( - setNewProjectOpen(true)} onImport={handleImportModpkg} /> - ); + return ; } - return ( - - ); + return ; } return (
- setGitRepoDialogOpen(true)} - onNewProject={() => setNewProjectOpen(true)} - isImporting={ - importFromModpkg.isPending || - peekFantome.isPending || - importFromFantome.isPending || - importFromGitRepo.isPending || - Boolean(fantomeProgress) || - Boolean(gitImportProgress) - } - /> - +
{renderContent()}
- - setNewProjectOpen(false)} - onSubmit={handleCreateProject} - isPending={createProject.isPending} - /> - - - - - - - - setGitRepoDialogOpen(false)} - onSubmit={handleImportGitRepoSubmit} - isPending={importFromGitRepo.isPending} - /> + + + + + +
); } diff --git a/src/stores/index.ts b/src/stores/index.ts index 7969fd0..a3d723c 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,3 +1,7 @@ export * from "./devConsole"; export * from "./libraryFilter"; export * from "./notifications"; +export * from "./patcherSession"; +export * from "./workshopDialogs"; +export * from "./workshopSelection"; +export * from "./workshopView"; diff --git a/src/stores/patcherSession.ts b/src/stores/patcherSession.ts new file mode 100644 index 0000000..61f0d91 --- /dev/null +++ b/src/stores/patcherSession.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +interface TestingProject { + path: string; + displayName: string; +} + +interface PatcherSessionStore { + testingProjects: TestingProject[]; + setTestingProjects: (projects: TestingProject[]) => void; + clearTestingProjects: () => void; +} + +export const usePatcherSessionStore = create((set) => ({ + testingProjects: [], + setTestingProjects: (projects) => set({ testingProjects: projects }), + clearTestingProjects: () => set({ testingProjects: [] }), +})); diff --git a/src/stores/workshopDialogs.ts b/src/stores/workshopDialogs.ts new file mode 100644 index 0000000..750a494 --- /dev/null +++ b/src/stores/workshopDialogs.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; + +import type { FantomePeekResult, WorkshopProject } from "@/lib/tauri"; + +interface WorkshopDialogsStore { + packProject: WorkshopProject | null; + deleteProject: WorkshopProject | null; + newProjectOpen: boolean; + fantomeImport: { peekResult: FantomePeekResult; filePath: string } | null; + gitImportOpen: boolean; + + openPackDialog: (project: WorkshopProject) => void; + closePackDialog: () => void; + openDeleteDialog: (project: WorkshopProject) => void; + closeDeleteDialog: () => void; + openNewProjectDialog: () => void; + closeNewProjectDialog: () => void; + openFantomeImportDialog: (peekResult: FantomePeekResult, filePath: string) => void; + closeFantomeImportDialog: () => void; + openGitImportDialog: () => void; + closeGitImportDialog: () => void; +} + +export const useWorkshopDialogsStore = create((set) => ({ + packProject: null, + deleteProject: null, + newProjectOpen: false, + fantomeImport: null, + gitImportOpen: false, + + openPackDialog: (project) => set({ packProject: project }), + closePackDialog: () => set({ packProject: null }), + openDeleteDialog: (project) => set({ deleteProject: project }), + closeDeleteDialog: () => set({ deleteProject: null }), + openNewProjectDialog: () => set({ newProjectOpen: true }), + closeNewProjectDialog: () => set({ newProjectOpen: false }), + openFantomeImportDialog: (peekResult, filePath) => + set({ fantomeImport: { peekResult, filePath } }), + closeFantomeImportDialog: () => set({ fantomeImport: null }), + openGitImportDialog: () => set({ gitImportOpen: true }), + closeGitImportDialog: () => set({ gitImportOpen: false }), +})); diff --git a/src/stores/workshopSelection.ts b/src/stores/workshopSelection.ts new file mode 100644 index 0000000..7bf095a --- /dev/null +++ b/src/stores/workshopSelection.ts @@ -0,0 +1,24 @@ +import { create } from "zustand"; + +interface WorkshopSelectionStore { + selectedPaths: Set; + toggle: (path: string) => void; + selectAll: (paths: string[]) => void; + clear: () => void; +} + +export const useWorkshopSelectionStore = create((set) => ({ + selectedPaths: new Set(), + toggle: (path) => + set((state) => { + const next = new Set(state.selectedPaths); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return { selectedPaths: next }; + }), + selectAll: (paths) => set({ selectedPaths: new Set(paths) }), + clear: () => set({ selectedPaths: new Set() }), +})); diff --git a/src/stores/workshopView.ts b/src/stores/workshopView.ts new file mode 100644 index 0000000..d7813aa --- /dev/null +++ b/src/stores/workshopView.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +import type { ViewMode } from "@/modules/workshop"; + +interface WorkshopViewStore { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + searchQuery: string; + setSearchQuery: (query: string) => void; +} + +export const useWorkshopViewStore = create((set) => ({ + viewMode: "grid", + setViewMode: (mode) => set({ viewMode: mode }), + searchQuery: "", + setSearchQuery: (query) => set({ searchQuery: query }), +}));