diff --git a/src/modules/library/api/useLibraryContent.ts b/src/modules/library/api/useLibraryContent.ts
index 8b90b4bc..d40ee1a1 100644
--- a/src/modules/library/api/useLibraryContent.ts
+++ b/src/modules/library/api/useLibraryContent.ts
@@ -140,6 +140,7 @@ export function useLibraryContent({
return {
viewMode,
dndDisabled,
+ selectMode,
contentView,
detailsMod,
setDetailsMod,
diff --git a/src/modules/library/components/LibraryContent.tsx b/src/modules/library/components/LibraryContent.tsx
index 5a9fc249..8588bb18 100644
--- a/src/modules/library/components/LibraryContent.tsx
+++ b/src/modules/library/components/LibraryContent.tsx
@@ -24,17 +24,30 @@ export function LibraryContent({
error,
folderId,
}: LibraryContentProps) {
- const { viewMode, dndDisabled, contentView, detailsMod, setDetailsMod, editMod, setEditMod } =
- useLibraryContent({
- mods,
- searchQuery,
- isLoading,
- hasError: error !== null,
- folderId,
- });
+ const {
+ viewMode,
+ dndDisabled,
+ selectMode,
+ contentView,
+ detailsMod,
+ setDetailsMod,
+ editMod,
+ setEditMod,
+ } = useLibraryContent({
+ mods,
+ searchQuery,
+ isLoading,
+ hasError: error !== null,
+ folderId,
+ });
const reorderMods = useReorderMods();
const reorderFolderMods = useReorderFolderMods();
+ // Extra bottom padding in select mode so the floating action bar never covers the last row.
+ const scrollClass = selectMode
+ ? "flex-1 overflow-auto px-6 pt-6 pb-28"
+ : "flex-1 overflow-auto p-6";
+
if (contentView.type === "loading") {
return (
@@ -63,7 +76,7 @@ export function LibraryContent({
return (
<>
-
+
-
+
-
+
}
>
- {selectMode ? "Done" : "Select"}
+ Select
@@ -210,7 +209,6 @@ export function LibraryToolbar({
)}
- {selectMode &&
}
);
}
diff --git a/src/modules/library/components/ModCard.tsx b/src/modules/library/components/ModCard.tsx
index b321a5c5..b5bb89d5 100644
--- a/src/modules/library/components/ModCard.tsx
+++ b/src/modules/library/components/ModCard.tsx
@@ -53,6 +53,7 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar
const selectMode = useLibrarySelectionStore((s) => s.selectMode);
const isSelected = useLibrarySelectionStore((s) => s.selectedIds.has(mod.id));
const toggleSelection = useLibrarySelectionStore((s) => s.toggle);
+ const selectRangeTo = useLibrarySelectionStore((s) => s.selectRangeTo);
const {
isFlagged,
@@ -112,7 +113,8 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar
return;
}
if (selectMode) {
- toggleSelection(mod.id);
+ if (e.shiftKey) selectRangeTo(mod.id);
+ else toggleSelection(mod.id);
return;
}
if (disabled) return;
@@ -154,11 +156,11 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar
)}
>
{selectMode && (
- e.stopPropagation()} className="shrink-0">
+
toggleSelection(mod.id)}
+ tabIndex={-1}
aria-label={`Select ${mod.displayName}`}
/>
@@ -318,15 +320,11 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar
)}
>
{selectMode && (
-
e.stopPropagation()}
- >
+
toggleSelection(mod.id)}
+ tabIndex={-1}
aria-label={`Select ${mod.displayName}`}
className="shadow-lg backdrop-blur-sm"
/>
diff --git a/src/modules/library/components/SelectionActionBar.tsx b/src/modules/library/components/SelectionActionBar.tsx
index 32aed2b7..46df09a2 100644
--- a/src/modules/library/components/SelectionActionBar.tsx
+++ b/src/modules/library/components/SelectionActionBar.tsx
@@ -1,7 +1,8 @@
-import { CheckSquare, Trash2, X } from "lucide-react";
+import { Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
-import { Button, useToast } from "@/components";
+import { Button, Checkbox, IconButton, Tooltip, useToast } from "@/components";
import type { InstalledMod } from "@/lib/tauri";
import { useBulkUninstallMods, useInstalledMods } from "@/modules/library/api";
import { usePatcherStatus } from "@/modules/patcher";
@@ -15,7 +16,9 @@ interface SelectionActionBarProps {
export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) {
const selectedIds = useLibrarySelectionStore((s) => s.selectedIds);
- const selectAll = useLibrarySelectionStore((s) => s.selectAll);
+ const addMany = useLibrarySelectionStore((s) => s.addMany);
+ const removeMany = useLibrarySelectionStore((s) => s.removeMany);
+ const setSelection = useLibrarySelectionStore((s) => s.setSelection);
const clear = useLibrarySelectionStore((s) => s.clear);
const exitSelectMode = useLibrarySelectionStore((s) => s.exitSelectMode);
@@ -28,28 +31,28 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) {
const { data: allMods = [] } = useInstalledMods();
const [dialogOpen, setDialogOpen] = useState(false);
+ // Snapshot of the mods to uninstall, frozen at confirm-dialog open so the optimistic
+ // cache update (which empties the live selection mid-mutation) can't blank the preview.
+ const [pendingMods, setPendingMods] = useState([]);
const selectedMods = useMemo(
() => allMods.filter((m) => selectedIds.has(m.id)),
[allMods, selectedIds],
);
- const selectedCount = selectedIds.size;
+ const selectedCount = selectedMods.length;
const visibleIds = useMemo(() => visibleMods.map((m) => m.id), [visibleMods]);
- const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id));
-
- function handleSelectAllVisible() {
- const union = new Set(selectedIds);
- for (const id of visibleIds) union.add(id);
- selectAll([...union]);
- }
-
- function handleClear() {
- clear();
- }
+ const visibleSelectedCount = useMemo(
+ () => visibleMods.reduce((n, m) => n + (selectedIds.has(m.id) ? 1 : 0), 0),
+ [visibleMods, selectedIds],
+ );
+ const allVisibleSelected = visibleIds.length > 0 && visibleSelectedCount === visibleIds.length;
+ const someVisibleSelected = visibleSelectedCount > 0 && !allVisibleSelected;
+ const hiddenCount = selectedCount - visibleSelectedCount;
function handleOpenDialog() {
if (selectedCount === 0) return;
+ setPendingMods(selectedMods);
setDialogOpen(true);
}
@@ -58,8 +61,21 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) {
setDialogOpen(false);
}
+ useHotkeys("escape", () => exitSelectMode(), { enabled: !dialogOpen }, [dialogOpen]);
+ useHotkeys(
+ "ctrl+a, meta+a",
+ (e) => {
+ e.preventDefault();
+ addMany(visibleIds);
+ },
+ { enabled: !dialogOpen, preventDefault: true },
+ [dialogOpen, visibleIds],
+ );
+
async function handleConfirmUninstall() {
- const ids = [...selectedIds];
+ const ids = pendingMods.map((m) => m.id);
+ if (ids.length === 0) return;
+
try {
const result = await bulkUninstall.mutateAsync(ids);
setDialogOpen(false);
@@ -69,19 +85,20 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) {
"Mods uninstalled",
`${result.succeeded.length} mod${result.succeeded.length === 1 ? "" : "s"} removed`,
);
+ exitSelectMode();
} else if (result.succeeded.length === 0) {
toast.error(
"Uninstall failed",
`All ${result.failed.length} mod${result.failed.length === 1 ? "" : "s"} failed to uninstall`,
);
+ setSelection(result.failed.map((f) => f.id));
} else {
toast.warning(
"Uninstall completed with errors",
`${result.succeeded.length} removed, ${result.failed.length} failed`,
);
+ setSelection(result.failed.map((f) => f.id));
}
-
- exitSelectMode();
} catch (error: unknown) {
toast.error("Uninstall failed", error instanceof Error ? error.message : String(error));
}
@@ -89,28 +106,49 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) {
return (
<>
-
-
- {selectedCount} selected
-
-
-
-
-
}
- >
- Select all visible ({visibleIds.length})
-
-
-
-
-
+
+
+
+ }
+ variant="ghost"
+ size="sm"
+ onClick={exitSelectMode}
+ disabled={bulkUninstall.isPending}
+ aria-label="Exit select mode"
+ />
+
+
+
+ {selectedCount} selected
+ {hiddenCount > 0 && (
+ · {hiddenCount} hidden
+ )}
+
+
+
+
+
(checked ? addMany(visibleIds) : removeMany(visibleIds))}
+ label={`Select all visible (${visibleIds.length})`}
+ className="items-center rounded-lg px-2 py-1.5 whitespace-nowrap transition-colors hover:bg-surface-700"
+ />
+
+
+
+
+
}
className="bg-red-600 hover:bg-red-500"
>
- Uninstall {selectedCount > 0 ? selectedCount : ""} mod{selectedCount === 1 ? "" : "s"}
-
-
- }
- >
- Exit
+ Uninstall{selectedCount > 0 ? ` ${selectedCount}` : ""} mod
+ {selectedCount === 1 ? "" : "s"}
m.enabled);
const visibleMods = useFilteredMods(mods, searchQuery);
+ const selectMode = useLibrarySelectionStore((s) => s.selectMode);
+ const setOrderedIds = useLibrarySelectionStore((s) => s.setOrderedIds);
+ useEffect(() => {
+ setOrderedIds(visibleMods.map((m) => m.id));
+ }, [visibleMods, setOrderedIds]);
+
useHotkeys("ctrl+i", () => actions.handleInstallMod(), {
preventDefault: true,
enabled: !isPatcherActive,
@@ -159,6 +167,7 @@ export function Library({ folderId }: LibraryProps = {}) {
error={error}
folderId={folderId}
/>
+ {selectMode && }
;
+ /** Visual order of the currently selectable mod ids, used to resolve shift-click ranges. */
+ orderedIds: string[];
+ /** Id of the last mod toggled without shift — the anchor for range selection. */
+ anchorId: string | null;
enterSelectMode: () => void;
exitSelectMode: () => void;
+ setOrderedIds: (ids: string[]) => void;
toggle: (id: string) => void;
- selectAll: (ids: string[]) => void;
+ selectRangeTo: (id: string) => void;
+ addMany: (ids: string[]) => void;
+ removeMany: (ids: string[]) => void;
+ setSelection: (ids: Iterable) => void;
clear: () => void;
}
export const useLibrarySelectionStore = create()((set) => ({
selectMode: false,
selectedIds: new Set(),
+ orderedIds: [],
+ anchorId: null,
enterSelectMode: () => set({ selectMode: true }),
- exitSelectMode: () => set({ selectMode: false, selectedIds: new Set() }),
+ exitSelectMode: () => set({ selectMode: false, selectedIds: new Set(), anchorId: null }),
+ setOrderedIds: (ids) =>
+ set((state) => (sameOrder(state.orderedIds, ids) ? state : { orderedIds: ids })),
toggle: (id) =>
set((state) => {
const next = new Set(state.selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
+ return { selectedIds: next, anchorId: id };
+ }),
+ selectRangeTo: (id) =>
+ set((state) => {
+ const { orderedIds, anchorId } = state;
+ const from = anchorId === null ? -1 : orderedIds.indexOf(anchorId);
+ const to = orderedIds.indexOf(id);
+ const next = new Set(state.selectedIds);
+ if (from === -1 || to === -1) {
+ next.add(id);
+ return { selectedIds: next, anchorId: id };
+ }
+ const [start, end] = from <= to ? [from, to] : [to, from];
+ for (let i = start; i <= end; i++) next.add(orderedIds[i]);
+ return { selectedIds: next, anchorId: id };
+ }),
+ addMany: (ids) =>
+ set((state) => {
+ const next = new Set(state.selectedIds);
+ for (const id of ids) next.add(id);
return { selectedIds: next };
}),
- selectAll: (ids) => set({ selectedIds: new Set(ids) }),
- clear: () => set({ selectedIds: new Set() }),
+ removeMany: (ids) =>
+ set((state) => {
+ const next = new Set(state.selectedIds);
+ for (const id of ids) next.delete(id);
+ return { selectedIds: next };
+ }),
+ setSelection: (ids) => set({ selectedIds: new Set(ids), anchorId: null }),
+ clear: () => set({ selectedIds: new Set(), anchorId: null }),
}));
+
+function sameOrder(a: string[], b: string[]): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+}
diff --git a/src/styles/animations.css b/src/styles/animations.css
index 6251d37c..59e6a396 100644
--- a/src/styles/animations.css
+++ b/src/styles/animations.css
@@ -140,3 +140,7 @@
.animate-slide-down {
animation: slide-down var(--duration-005) var(--ease-out);
}
+
+.animate-slide-up {
+ animation: slide-up var(--duration-005) var(--ease-out);
+}