Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ src-tauri/src/lib/bindings/
.mcp.json

# Windows
nul
nul
2 changes: 2 additions & 0 deletions src/modules/library/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export { libraryKeys } from "./keys";
export { useAnalyzeModWads } from "./useAnalyzeModWads";
export { useBulkInstallMods } from "./useBulkInstallMods";
export type { BulkUninstallResult } from "./useBulkUninstallMods";
export { useBulkUninstallMods } from "./useBulkUninstallMods";
export { useCreateProfile } from "./useCreateProfile";
export { useDeleteProfile } from "./useDeleteProfile";
export { useEditMod } from "./useEditMod";
Expand Down
56 changes: 56 additions & 0 deletions src/modules/library/api/useBulkUninstallMods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { api, type AppError, type InstalledMod } from "@/lib/tauri";
import { isOk } from "@/utils/result";

import { libraryKeys } from "./keys";

export interface BulkUninstallResult {
succeeded: string[];
failed: Array<{ id: string; error: string }>;
}

export function useBulkUninstallMods() {
const queryClient = useQueryClient();

return useMutation<BulkUninstallResult, AppError, string[], { previous?: InstalledMod[] }>({
mutationFn: async (modIds) => {
const settled = await Promise.allSettled(modIds.map((id) => api.uninstallMod(id)));

const succeeded: string[] = [];
const failed: Array<{ id: string; error: string }> = [];

settled.forEach((outcome, index) => {
const id = modIds[index];
if (outcome.status === "rejected") {
failed.push({ id, error: String(outcome.reason) });
return;
}
if (isOk(outcome.value)) {
succeeded.push(id);
} else {
failed.push({ id, error: outcome.value.error.message });
}
});

return { succeeded, failed };
},
onMutate: async (modIds) => {
await queryClient.cancelQueries({ queryKey: libraryKeys.mods() });
const previous = queryClient.getQueryData<InstalledMod[]>(libraryKeys.mods());
const idSet = new Set(modIds);
queryClient.setQueryData<InstalledMod[]>(libraryKeys.mods(), (old) =>
old?.filter((mod) => !idSet.has(mod.id)),
);
return { previous };
},
onError: (_error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(libraryKeys.mods(), context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: libraryKeys.mods() });
},
});
}
20 changes: 19 additions & 1 deletion src/modules/library/api/useLibraryActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { open } from "@tauri-apps/plugin-dialog";
import { useState } from "react";

import { useToast } from "@/components";
import { api, type BulkInstallResult, unwrap } from "@/lib/tauri";
import { api, type BulkInstallResult, type InstalledMod, unwrap } from "@/lib/tauri";
import { checkModForSkinhack } from "@/modules/library/utils/skinhackCheck";

import { useBulkInstallMods } from "./useBulkInstallMods";
Expand Down Expand Up @@ -113,6 +113,22 @@ export function useLibraryActions() {
);
}

function handleSetEnabledForMods(mods: InstalledMod[], enabled: boolean) {
const targets = mods.filter((m) => m.enabled !== enabled);
if (targets.length === 0) return;

for (const mod of targets) {
toggleMod.mutate(
{ modId: mod.id, enabled },
{
onError: (error) => {
console.error("Failed to toggle mod:", error.message);
},
},
);
}
}

function handleUninstallMod(modId: string) {
uninstallMod.mutate(modId, {
onError: (error) => {
Expand Down Expand Up @@ -141,9 +157,11 @@ export function useLibraryActions() {
return {
installMod,
bulkInstallMods,
toggleMod,
handleInstallMod,
handleBulkInstallFiles,
handleToggleMod,
handleSetEnabledForMods,
handleUninstallMod,
handleReorder,
handleOpenStorageDirectory,
Expand Down
6 changes: 4 additions & 2 deletions src/modules/library/api/useLibraryContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import type { InstalledMod, LibraryFolder } from "@/lib/tauri";
import { sortFolders, sortModsByFolder } from "@/modules/library/utils";
import { usePatcherStatus } from "@/modules/patcher";
import { useHasActiveFilters, useLibraryFilterStore } from "@/stores";
import { useHasActiveFilters, useLibraryFilterStore, useLibrarySelectionStore } from "@/stores";
import { useLibraryViewStore } from "@/stores/libraryView";

import { useFolderOrder, useFolders } from "./queries";
Expand Down Expand Up @@ -58,9 +58,11 @@ export function useLibraryContent({
cleanupStaleFolders(validIds);
}, [folders, cleanupStaleFolders]);

const selectMode = useLibrarySelectionStore((s) => s.selectMode);
const isSearching = searchQuery.length > 0;
const isPrioritySort = sort.field === "priority";
const dndDisabled = isSearching || isPatcherActive || !isPrioritySort || hasActiveFilters;
const dndDisabled =
isSearching || isPatcherActive || !isPrioritySort || hasActiveFilters || selectMode;
const isFlatMode = isSearching || hasActiveFilters;

const folderMap = useMemo(() => {
Expand Down
92 changes: 92 additions & 0 deletions src/modules/library/components/BulkUninstallDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { TriangleAlert } from "lucide-react";

import { Button, Dialog } from "@/components";
import type { InstalledMod } from "@/lib/tauri";

interface BulkUninstallDialogProps {
open: boolean;
mods: InstalledMod[];
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
}

const PREVIEW_LIMIT = 5;

export function BulkUninstallDialog({
open,
mods,
isPending,
onClose,
onConfirm,
}: BulkUninstallDialogProps) {
const count = mods.length;
const preview = mods.slice(0, PREVIEW_LIMIT);
const overflow = Math.max(0, count - PREVIEW_LIMIT);

return (
<Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Overlay>
<Dialog.Header>
<Dialog.Title>
Uninstall {count} mod{count === 1 ? "" : "s"}?
</Dialog.Title>
<Dialog.Close />
</Dialog.Header>

<Dialog.Body>
<div className="flex items-start gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<TriangleAlert className="mt-0.5 h-5 w-5 shrink-0 text-red-400" />
<div className="min-w-0">
<h3 className="font-medium text-red-300">
This will permanently delete the selected mod files from disk.
</h3>
<p className="mt-1 text-sm text-surface-400">
You&rsquo;ll need to re-import them from their original archives to use them
again.
</p>
<p className="mt-2 text-xs text-surface-500">This action cannot be undone.</p>
</div>
</div>

{preview.length > 0 && (
<div className="mt-4">
<p className="mb-2 text-xs font-medium tracking-wide text-surface-400 uppercase">
To be removed
</p>
<ul className="space-y-1 text-sm text-surface-200">
{preview.map((mod) => (
<li key={mod.id} className="truncate">
• {mod.displayName}
</li>
))}
</ul>
{overflow > 0 && (
<p className="mt-2 text-xs text-surface-500">
+ {overflow} more mod{overflow === 1 ? "" : "s"}
</p>
)}
</div>
)}
</Dialog.Body>

<Dialog.Footer>
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
variant="filled"
onClick={onConfirm}
loading={isPending}
className="bg-red-600 hover:bg-red-500"
>
Uninstall {count} mod{count === 1 ? "" : "s"}
</Button>
</Dialog.Footer>
</Dialog.Overlay>
</Dialog.Portal>
</Dialog.Root>
);
}
59 changes: 57 additions & 2 deletions src/modules/library/components/LibraryToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Grid3X3, List, Play, Plus, Search } from "lucide-react";
import { CheckCheck, CheckSquare, Grid3X3, List, Play, Plus, Search, X } from "lucide-react";

import { Button, IconButton, Kbd, Tooltip } from "@/components";
import type { PatcherStatus } from "@/lib/tauri";
import type { InstalledMod, PatcherStatus } from "@/lib/tauri";
import type { FilterOptions } from "@/modules/library/api";
import type { useLibraryActions } from "@/modules/library/api";
import { useLibraryViewMode } from "@/modules/library/api";
import { useLibrarySelectionStore } from "@/stores";

import { ActiveFilterChips } from "./ActiveFilterChips";
import { FilterPopover } from "./FilterPopover";
import { SelectionActionBar } from "./SelectionActionBar";
import { SortDropdown } from "./SortDropdown";

interface PatcherProps {
Expand All @@ -27,6 +29,7 @@ interface LibraryToolbarProps {
isLoading: boolean;
isPatcherActive: boolean;
filterOptions: FilterOptions;
visibleMods: InstalledMod[];
}

export function LibraryToolbar({
Expand All @@ -38,8 +41,16 @@ export function LibraryToolbar({
isLoading,
isPatcherActive,
filterOptions,
visibleMods,
}: LibraryToolbarProps) {
const { viewMode, setViewMode } = useLibraryViewMode();
const selectMode = useLibrarySelectionStore((s) => s.selectMode);
const enterSelectMode = useLibrarySelectionStore((s) => s.enterSelectMode);
const exitSelectMode = useLibrarySelectionStore((s) => s.exitSelectMode);
const visibleEnabledCount = visibleMods.reduce((n, m) => n + (m.enabled ? 1 : 0), 0);
const canEnableAll = visibleMods.length > 0 && visibleEnabledCount < visibleMods.length;
const canDisableAll = visibleEnabledCount > 0;
const bulkDisabled = isPatcherActive || isLoading || actions.toggleMod.isPending;

return (
<div className="border-b border-surface-600 bg-surface-800/50 px-4 py-3" data-tauri-drag-region>
Expand Down Expand Up @@ -80,6 +91,49 @@ export function LibraryToolbar({
</Tooltip>
</div>

{/* Bulk toggle */}
<div className="flex items-center gap-1">
<Tooltip content="Enable every mod matching the current search/filters">
<IconButton
icon={<CheckCheck className="h-4 w-4" />}
variant="ghost"
size="sm"
onClick={() => actions.handleSetEnabledForMods(visibleMods, true)}
disabled={bulkDisabled || !canEnableAll}
aria-label="Enable all visible mods"
/>
</Tooltip>
<Tooltip content="Disable every mod matching the current search/filters">
<IconButton
icon={<X className="h-4 w-4" />}
variant="ghost"
size="sm"
onClick={() => actions.handleSetEnabledForMods(visibleMods, false)}
disabled={bulkDisabled || !canDisableAll}
aria-label="Disable all visible mods"
/>
</Tooltip>
</div>

{/* Select mode toggle */}
<Tooltip
content={
selectMode
? "Exit select mode"
: "Select mods to bulk-uninstall (combine with search/filters to narrow down)"
}
>
<Button
variant={selectMode ? "filled" : "outline"}
size="sm"
onClick={selectMode ? exitSelectMode : enterSelectMode}
disabled={isPatcherActive || isLoading}
left={<CheckSquare className="h-4 w-4" />}
>
{selectMode ? "Done" : "Select"}
</Button>
</Tooltip>

{/* Actions */}
<Tooltip
content={
Expand Down Expand Up @@ -156,6 +210,7 @@ export function LibraryToolbar({
)}
</div>
<ActiveFilterChips />
{selectMode && <SelectionActionBar visibleMods={visibleMods} />}
</div>
);
}
Loading