Skip to content

Commit 76fddc4

Browse files
authored
fix(torrents): stabilize tag and category dialogs (#1638)
1 parent d8ad0d6 commit 76fddc4

2 files changed

Lines changed: 75 additions & 122 deletions

File tree

web/src/components/torrents/TorrentDialogs.tsx

Lines changed: 65 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,17 +1049,31 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
10491049
}: SetCategoryDialogProps) {
10501050
const [categoryInput, setCategoryInput] = useState("")
10511051
const [searchQuery, setSearchQuery] = useState("")
1052+
const [dialogCategories, setDialogCategories] = useState<Record<string, Category>>({})
1053+
const [dialogUseSubcategories, setDialogUseSubcategories] = useState(useSubcategories)
10521054
const wasOpen = useRef(false)
10531055
const scrollContainerRef = useRef<HTMLDivElement>(null)
1056+
const availableCategoryCount = Object.keys(availableCategories || {}).length
1057+
const dialogCategoryCount = Object.keys(dialogCategories).length
10541058

1055-
// Initialize category only when dialog transitions from closed to open
1059+
// Freeze the category list while the dialog is open so background table refreshes
1060+
// do not reshuffle the scroll container. If the dialog opened before categories
1061+
// finished loading, hydrate exactly once when the first non-empty list arrives.
10561062
useEffect(() => {
10571063
if (open && !wasOpen.current) {
10581064
setCategoryInput(initialCategory)
10591065
setSearchQuery("")
1066+
setDialogCategories(availableCategories || {})
1067+
setDialogUseSubcategories(useSubcategories)
1068+
} else if (open && dialogCategoryCount === 0 && availableCategoryCount > 0) {
1069+
setDialogCategories(availableCategories || {})
1070+
setDialogUseSubcategories(useSubcategories)
1071+
} else if (!open && wasOpen.current) {
1072+
setDialogCategories({})
1073+
setDialogUseSubcategories(useSubcategories)
10601074
}
10611075
wasOpen.current = open
1062-
}, [open, initialCategory])
1076+
}, [availableCategories, availableCategoryCount, dialogCategoryCount, initialCategory, open, useSubcategories])
10631077

10641078
const handleConfirm = useCallback(() => {
10651079
onConfirm(categoryInput)
@@ -1074,13 +1088,13 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
10741088
}, [onOpenChange])
10751089

10761090
// Filter categories based on search, with subcategory support
1077-
const categoryList = Object.keys(availableCategories || {}).sort()
1091+
const categoryList = useMemo(() => Object.keys(dialogCategories).sort(), [dialogCategories])
10781092

10791093
const filteredCategories = useMemo(() => {
10801094
const query = searchQuery.trim().toLowerCase()
10811095

1082-
if (useSubcategories) {
1083-
const tree = buildCategoryTree(availableCategories || {}, {})
1096+
if (dialogUseSubcategories) {
1097+
const tree = buildCategoryTree(dialogCategories, {})
10841098
const shouldIncludeCache = new Map<CategoryNode, boolean>()
10851099

10861100
const shouldIncludeNode = (node: CategoryNode): boolean => {
@@ -1133,16 +1147,10 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
11331147
displayName: name,
11341148
level: 0,
11351149
}))
1136-
}, [availableCategories, categoryList, searchQuery, useSubcategories])
1150+
}, [categoryList, dialogCategories, dialogUseSubcategories, searchQuery])
11371151

1138-
const shouldUseVirtualization = filteredCategories.length > 50
1139-
1140-
const virtualizer = useVirtualizer({
1141-
count: shouldUseVirtualization ? filteredCategories.length : 0,
1142-
getScrollElement: () => scrollContainerRef.current,
1143-
estimateSize: () => 36,
1144-
overscan: 5,
1145-
})
1152+
const showLoadingCategories = isLoadingCategories && dialogCategoryCount === 0
1153+
const showSearch = !showLoadingCategories && categoryList.length > 10
11461154

11471155
return (
11481156
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -1155,89 +1163,47 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
11551163
</DialogHeader>
11561164
<div className="py-4 space-y-4">
11571165
{/* Search bar for categories */}
1158-
{!isLoadingCategories && categoryList.length > 10 && (
1159-
<div className="space-y-2">
1160-
<Label htmlFor="categorySearch">Search Categories</Label>
1161-
<Input
1162-
id="categorySearch"
1163-
placeholder="Type to search..."
1164-
value={searchQuery}
1165-
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
1166-
/>
1167-
</div>
1168-
)}
1166+
<div className={showSearch ? "space-y-2" : "hidden"} aria-hidden={!showSearch}>
1167+
{showSearch && (
1168+
<>
1169+
<Label htmlFor="categorySearch">Search Categories</Label>
1170+
<Input
1171+
id="categorySearch"
1172+
placeholder="Type to search..."
1173+
value={searchQuery}
1174+
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
1175+
/>
1176+
</>
1177+
)}
1178+
</div>
11691179

11701180
{/* Category list with optional virtualization */}
11711181
<div className="space-y-2">
11721182
<Label>Select Category</Label>
1173-
{isLoadingCategories ? (
1174-
<div className="max-h-64 border rounded-md p-3 flex items-center justify-center">
1175-
<div className="flex items-center gap-2 text-muted-foreground">
1176-
<Loader2 className="h-4 w-4 animate-spin" />
1177-
<span className="text-sm">Loading categories...</span>
1183+
<div
1184+
ref={scrollContainerRef}
1185+
className="max-h-64 border rounded-md overflow-y-auto"
1186+
>
1187+
{showLoadingCategories ? (
1188+
<div className="p-3 flex items-center justify-center">
1189+
<div className="flex items-center gap-2 text-muted-foreground">
1190+
<Loader2 className="h-4 w-4 animate-spin" />
1191+
<span className="text-sm">Loading categories...</span>
1192+
</div>
11781193
</div>
1179-
</div>
1180-
) : (
1181-
<div
1182-
ref={scrollContainerRef}
1183-
className="max-h-64 border rounded-md overflow-y-auto"
1184-
>
1185-
{/* No category option */}
1186-
<button
1187-
type="button"
1188-
onClick={() => setCategoryInput("")}
1189-
className={`w-full text-left px-3 py-2 hover:bg-accent transition-colors ${
1190-
categoryInput === "" ? "bg-accent" : ""
1191-
}`}
1192-
>
1193-
<span className="text-sm text-muted-foreground italic">(No category)</span>
1194-
</button>
1195-
1196-
{shouldUseVirtualization ? (
1197-
// Virtualized rendering for large lists
1198-
<div
1199-
style={{
1200-
height: `${virtualizer.getTotalSize()}px`,
1201-
width: "100%",
1202-
position: "relative",
1203-
}}
1194+
) : (
1195+
<>
1196+
{/* No category option */}
1197+
<button
1198+
type="button"
1199+
onClick={() => setCategoryInput("")}
1200+
className={`w-full text-left px-3 py-2 hover:bg-accent transition-colors ${
1201+
categoryInput === "" ? "bg-accent" : ""
1202+
}`}
12041203
>
1205-
{virtualizer.getVirtualItems().map((virtualRow) => {
1206-
const category = filteredCategories[virtualRow.index]
1207-
return (
1208-
<div
1209-
key={virtualRow.key}
1210-
data-index={virtualRow.index}
1211-
ref={virtualizer.measureElement}
1212-
style={{
1213-
position: "absolute",
1214-
top: 0,
1215-
left: 0,
1216-
width: "100%",
1217-
transform: `translateY(${virtualRow.start}px)`,
1218-
}}
1219-
>
1220-
<button
1221-
type="button"
1222-
onClick={() => setCategoryInput(category.name)}
1223-
className={`w-full text-left px-3 py-2 hover:bg-accent transition-colors ${
1224-
categoryInput === category.name ? "bg-accent" : ""
1225-
}`}
1226-
title={category.name}
1227-
>
1228-
<span
1229-
className="text-sm"
1230-
style={category.level > 0 ? { paddingLeft: category.level * 12 } : undefined}
1231-
>
1232-
{category.displayName}
1233-
</span>
1234-
</button>
1235-
</div>
1236-
)
1237-
})}
1238-
</div>
1239-
) : (
1240-
// Simple rendering for small lists - much faster!
1204+
<span className="text-sm text-muted-foreground italic">(No category)</span>
1205+
</button>
1206+
12411207
<div>
12421208
{filteredCategories.map((category) => (
12431209
<button
@@ -1258,15 +1224,15 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
12581224
</button>
12591225
))}
12601226
</div>
1261-
)}
12621227

1263-
{filteredCategories.length === 0 && searchQuery && (
1264-
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
1265-
No categories found matching "{searchQuery}"
1266-
</div>
1267-
)}
1268-
</div>
1269-
)}
1228+
{filteredCategories.length === 0 && searchQuery && (
1229+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
1230+
No categories found matching "{searchQuery}"
1231+
</div>
1232+
)}
1233+
</>
1234+
)}
1235+
</div>
12701236
</div>
12711237

12721238
{/* Option to enter new category */}

web/src/components/torrents/TorrentManagementBar.tsx

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -589,32 +589,19 @@ export const TorrentManagementBar = memo(function TorrentManagementBar({
589589
)
590590
})()}
591591

592-
{/* Tag Actions */}
593-
<DropdownMenu>
594-
<Tooltip>
595-
<TooltipTrigger asChild>
596-
<DropdownMenuTrigger asChild>
597-
<Button
598-
variant="ghost"
599-
size="sm"
600-
disabled={isPending || isDisabled}
601-
>
602-
<Tag className="h-4 w-4" />
603-
</Button>
604-
</DropdownMenuTrigger>
605-
</TooltipTrigger>
606-
<TooltipContent>Tag Actions</TooltipContent>
607-
</Tooltip>
608-
<DropdownMenuContent align="center">
609-
<DropdownMenuItem
592+
<Tooltip>
593+
<TooltipTrigger asChild>
594+
<Button
595+
variant="ghost"
596+
size="sm"
610597
onClick={() => prepareTagsAction(selectedHashes, selectedTorrents)}
611598
disabled={isPending || isDisabled}
612599
>
613-
<Tag className="h-4 w-4 mr-2" />
614-
Set Tags {selectionCount > 1 ? `(${selectionCount})` : ""}
615-
</DropdownMenuItem>
616-
</DropdownMenuContent>
617-
</DropdownMenu>
600+
<Tag className="h-4 w-4" />
601+
</Button>
602+
</TooltipTrigger>
603+
<TooltipContent>Set Tags</TooltipContent>
604+
</Tooltip>
618605

619606
<Tooltip>
620607
<TooltipTrigger asChild>

0 commit comments

Comments
 (0)