add folder browsing support (#315)

This commit is contained in:
jeffvli
2025-12-02 21:30:44 -08:00
parent 355257104d
commit 917bf91583
53 changed files with 2382 additions and 299 deletions
@@ -0,0 +1,21 @@
import { queryOptions } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { FolderQuery } from '/@/shared/types/domain-types';
export const folderQueries = {
folder: (args: QueryHookArgs<FolderQuery>) => {
return queryOptions({
queryFn: ({ signal }) => {
return api.controller.getFolder({
apiClientProps: { serverId: args.serverId, signal },
query: args.query,
});
},
queryKey: queryKeys.folders.folder(args.serverId, args.query),
...args.options,
});
},
};
@@ -0,0 +1,194 @@
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useCallback, useEffect, useMemo } from 'react';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { DefaultItemControlProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
import { FolderTreeBrowser } from '/@/renderer/features/folders/components/folder-tree-browser';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServerId, useListSettings, usePlayerSong } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Folder, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
export const FolderListContent = () => {
return (
<Suspense fallback={<Spinner container />}>
<FolderListInnerContent />
</Suspense>
);
};
export const FolderListInnerContent = () => {
const serverId = useCurrentServerId();
const queryClient = useQueryClient();
const { currentFolderId, query } = useFolderListFilters();
const getFolderQueryOptions = useCallback(
(folderId: string) => {
return folderQueries.folder({
query: {
id: folderId,
searchTerm: query[FILTER_KEYS.SHARED.SEARCH_TERM] as string | undefined,
sortBy:
(query[FILTER_KEYS.SHARED.SORT_BY] as SongListSort) || SongListSort.NAME,
sortOrder: (query[FILTER_KEYS.SHARED.SORT_ORDER] as SortOrder) || SortOrder.ASC,
},
serverId,
});
},
[serverId, query],
);
const rootFolderQuery = useQuery({
...getFolderQueryOptions('0'),
staleTime: 1000 * 60 * 5,
});
const currentFolderQuery = useSuspenseQuery({
...getFolderQueryOptions(currentFolderId),
staleTime: 1000 * 60,
});
const fetchFolder = useCallback(
async (folderId: string) => {
const queryOptions = getFolderQueryOptions(folderId);
return queryClient.fetchQuery({
...queryOptions,
staleTime: 1000 * 60 * 5,
});
},
[getFolderQueryOptions, queryClient],
);
return (
<>
<ListWithSidebarContainer.SidebarPortal>
<FolderTreeBrowser fetchFolder={fetchFolder} rootFolderQuery={rootFolderQuery} />
</ListWithSidebarContainer.SidebarPortal>
<FolderListView folderQuery={currentFolderQuery} />
</>
);
};
interface FolderListViewProps {
folderQuery: ReturnType<typeof useSuspenseQuery<Folder>>;
}
export const FolderListView = ({ folderQuery }: FolderListViewProps) => {
const { display, table } = useListSettings(ItemListKey.SONG);
const { setItemCount } = useListContext();
const { navigateToFolder } = useFolderListFilters();
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: true,
});
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.SONG,
});
const { handleColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.SONG,
});
const allItems = useMemo(() => {
if (!folderQuery.data?.children) {
return [];
}
const { folders = [], songs = [] } = folderQuery.data.children;
return [...folders, ...songs];
}, [folderQuery.data]);
useEffect(() => {
setItemCount?.(allItems.length);
}, [allItems.length, setItemCount]);
const player = usePlayer();
const overrideControls = useMemo(() => {
return {
onDoubleClick: ({ index, internalState, item }: DefaultItemControlProps) => {
if (!item) {
return;
}
if ((item as unknown as Folder)._itemType === LibraryItem.FOLDER) {
const folder = item as unknown as Folder;
return navigateToFolder(folder.id, folder.name);
}
const items = internalState?.getData() as Song[];
const songCount = items.filter(
(item) => item._itemType === LibraryItem.SONG,
).length;
const indexesToSkip = items.length - songCount;
const startIndex = indexesToSkip + (index ?? 0);
player.addToQueueByData(items, Play.NOW);
player.mediaPlayByIndex(startIndex);
},
};
}, [navigateToFolder, player]);
const currentSong = usePlayerSong();
switch (display) {
// case ListDisplayType.GRID: {
// return (
// <ItemGridList
// data={allItems}
// gap={grid.itemGap}
// initialTop={{
// to: scrollOffset ?? 0,
// type: 'offset',
// }}
// itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
// itemType={LibraryItem.FOLDER}
// onScrollEnd={handleOnScrollEnd}
// overrideControls={overrideControls}
// />
// );
// }
case ListDisplayType.TABLE: {
return (
<ItemTableList
activeRowId={currentSong?.id}
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={table.columns}
data={allItems}
enableAlternateRowColors={table.enableAlternateRowColors}
enableDrag={true}
enableExpansion={false}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.FOLDER}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={overrideControls}
size={table.size}
/>
);
}
default:
return null;
}
};
@@ -0,0 +1,264 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { useContainerQuery } from '/@/renderer/hooks';
import { truncateMiddle } from '/@/renderer/utils';
import { Breadcrumb } from '/@/shared/components/breadcrumb/breadcrumb';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
const MAX_BREADCRUMB_TEXT_LENGTH = 26;
export const FolderListHeaderFilters = () => {
const { t } = useTranslation();
const { folderPath, navigateToPathIndex, setFolderPath } = useFolderListFilters();
const {
is2xl,
isLg,
isMd,
isSm,
isXl,
isXs,
ref: breadcrumbContainerRef,
} = useContainerQuery();
const maxItems = useMemo(() => {
if (is2xl) return 8;
if (isXl) return 6;
if (isLg) return 4;
if (isMd) return 3;
if (isSm) return 2;
if (isXs) return 2;
return 1;
}, [is2xl, isLg, isMd, isSm, isXl, isXs]);
const allBreadcrumbItems = useMemo(() => {
const items: Array<{
fullLabel: string;
id: string;
label: string;
onClick: () => void;
}> = [];
const homeLabel = t('common.home', { postProcess: 'titleCase' });
items.push({
fullLabel: homeLabel,
id: 'folder-root',
label: homeLabel,
onClick: () => {
setFolderPath([]);
},
});
folderPath.forEach((folder, index) => {
items.push({
fullLabel: folder.name,
id: `folder-${folder.id}`,
label: truncateMiddle(folder.name, MAX_BREADCRUMB_TEXT_LENGTH),
onClick: () => navigateToPathIndex(index),
});
});
return items;
}, [folderPath, navigateToPathIndex, setFolderPath, t]);
const visibleItems = useMemo(() => {
const firstItem = allBreadcrumbItems[0];
if (maxItems === 1) {
return [firstItem];
}
if (allBreadcrumbItems.length <= maxItems) {
return allBreadcrumbItems;
}
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
const middleItems = allBreadcrumbItems.slice(1, -1);
const availableSlots = maxItems - 2;
if (availableSlots <= 0) {
return [firstItem, lastItem];
}
if (middleItems.length <= availableSlots) {
return [firstItem, ...middleItems, lastItem];
}
const startCount = Math.floor(availableSlots / 2);
const endCount = availableSlots - startCount;
const startMiddle = middleItems.slice(0, startCount);
const endMiddle = middleItems.slice(-endCount);
return [firstItem, ...startMiddle, ...endMiddle, lastItem];
}, [allBreadcrumbItems, maxItems]);
const collapsedItems = useMemo(() => {
if (maxItems === 1) {
return allBreadcrumbItems.slice(1);
}
if (allBreadcrumbItems.length <= maxItems) {
return [];
}
const middleItems = allBreadcrumbItems.slice(1, -1);
const availableSlots = maxItems - 2;
if (availableSlots <= 0) {
return middleItems;
}
if (middleItems.length <= availableSlots) {
return [];
}
const startCount = Math.floor(availableSlots / 2);
const endCount = availableSlots - startCount;
const visibleStart = middleItems.slice(0, startCount);
const visibleEnd = middleItems.slice(-endCount);
return middleItems.filter(
(item) => !visibleStart.includes(item) && !visibleEnd.includes(item),
);
}, [allBreadcrumbItems, maxItems]);
const breadcrumbItems = useMemo(() => {
const items: React.ReactNode[] = [];
const firstItem = allBreadcrumbItems[0];
const lastItem = allBreadcrumbItems[allBreadcrumbItems.length - 1];
const hasCollapsedItems = collapsedItems.length > 0;
const renderDropdown = () => (
<DropdownMenu key="breadcrumb-dropdown" position="bottom-start">
<DropdownMenu.Target>
<Button size="compact-sm" variant="subtle">
<Icon icon="ellipsisHorizontal" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{collapsedItems.map((collapsedItem) => (
<DropdownMenu.Item key={collapsedItem.id} onClick={collapsedItem.onClick}>
{collapsedItem.fullLabel}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
);
if (hasCollapsedItems && maxItems === 1) {
items.push(
<Button
key={firstItem.id}
onClick={firstItem.onClick}
size="compact-sm"
variant="subtle"
>
{firstItem.label}
</Button>,
);
items.push(renderDropdown());
return items;
}
if (hasCollapsedItems) {
const middleItems = allBreadcrumbItems.slice(1, -1);
const availableSlots = maxItems - 2;
const startCount = Math.floor(availableSlots / 2);
const visibleStartMiddle = middleItems.slice(0, startCount);
const visibleEndMiddle = middleItems.slice(-(availableSlots - startCount));
visibleItems.forEach((item, index) => {
items.push(
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
{item.label}
</Button>,
);
if (index < visibleItems.length - 1) {
const nextItem = visibleItems[index + 1];
const isFirstItem = item.id === firstItem.id;
const isLastStartMiddle =
item.id !== firstItem.id &&
item.id !== lastItem.id &&
visibleStartMiddle.length > 0 &&
item.id === visibleStartMiddle[visibleStartMiddle.length - 1].id;
const shouldInsertDropdown =
(isFirstItem && nextItem.id === lastItem.id) ||
(isLastStartMiddle &&
(nextItem.id === lastItem.id ||
(visibleEndMiddle.length > 0 &&
nextItem.id === visibleEndMiddle[0].id)));
if (shouldInsertDropdown) {
items.push(renderDropdown());
}
}
});
} else {
visibleItems.forEach((item) => {
items.push(
<Button key={item.id} onClick={item.onClick} size="compact-sm" variant="subtle">
{item.label}
</Button>,
);
});
}
return items;
}, [visibleItems, collapsedItems, allBreadcrumbItems, maxItems]);
return (
<Stack>
<Flex justify="space-between">
<Group gap="sm" w="100%">
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
itemType={LibraryItem.FOLDER}
listKey={ItemListKey.SONG}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButton
defaultSortOrder={SortOrder.ASC}
listKey={ItemListKey.SONG}
/>
<ListRefreshButton listKey={ItemListKey.SONG} />
</Group>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
listKey={ItemListKey.SONG}
optionsConfig={{
grid: {
itemsPerPage: { hidden: true },
pagination: { hidden: true },
},
table: {
itemsPerPage: { hidden: true },
pagination: { hidden: true },
},
}}
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
</Flex>
<div ref={breadcrumbContainerRef}>
<Breadcrumb separator={<Icon icon="arrowRight" />}>{breadcrumbItems}</Breadcrumb>
</div>
</Stack>
);
};
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { useListContext } from '/@/renderer/context/list-context';
import { FolderListHeaderFilters } from '/@/renderer/features/folders/components/folder-list-header-filters';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
interface FolderListHeaderProps {
title?: string;
}
export const FolderListHeader = ({ title }: FolderListHeaderProps) => {
const { t } = useTranslation();
const { itemCount } = useListContext();
const pageTitle = title || t('page.folderList.title', { postProcess: 'titleCase' });
return (
<Stack gap={0}>
<PageHeader>
<LibraryHeaderBar ignoreMaxWidth>
<Stack>
<LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
</Stack>
<LibraryHeaderBar.Badge isLoading={itemCount === undefined}>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<ListSearchInput />
</Group>
</PageHeader>
<FilterBar>
<FolderListHeaderFilters />
</FilterBar>
</Stack>
);
};
@@ -0,0 +1,96 @@
.container {
width: 100%;
height: 100%;
padding: var(--theme-spacing-sm);
}
.row {
display: flex;
align-items: center;
width: 100%;
height: 100%;
padding: 0 var(--theme-spacing-sm);
cursor: pointer;
user-select: none;
border-radius: var(--theme-radius-md);
transition: background-color 0.15s ease-in-out;
}
.row:hover {
background-color: var(--theme-colors-surface);
}
.row.active {
color: var(--theme-colors-primary-filled);
}
.row.dragging {
opacity: 0.5;
}
.row-content {
display: flex;
gap: var(--theme-spacing-xs);
align-items: center;
width: 100%;
}
.expand-icon-container {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.expand-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
color: var(--theme-colors-foreground);
transition: transform 0.2s ease-in-out;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
.expand-icon-placeholder {
display: flex;
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
.folder-icon-container {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.folder-icon {
flex-shrink: 0;
color: var(--theme-colors-foreground);
}
.folder-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-md);
color: var(--theme-colors-foreground);
white-space: nowrap;
}
.row.active .folder-name {
font-weight: 500;
color: var(--theme-colors-primary-filled);
}
.tooltip {
padding: var(--theme-spacing-sm) var(--theme-spacing-md) var(--theme-spacing-sm) 0;
font-size: var(--theme-font-size-lg);
font-weight: 500;
}
@@ -0,0 +1,489 @@
import { type UseQueryResult } from '@tanstack/react-query';
import clsx from 'clsx';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { List, RowComponentProps } from 'react-window-v2';
import styles from './folder-tree-browser.module.css';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { useFolderListFilters } from '/@/renderer/features/folders/hooks/use-folder-list-filters';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { Icon } from '/@/shared/components/icon/icon';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { Folder, LibraryItem } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
interface FlattenedNode {
depth: number;
folder: Folder;
hasChildren: boolean;
isExpanded: boolean;
path: Array<{ id: string; name: string }>;
}
interface TreeNode {
childrenLoaded: boolean;
depth: number;
folder: Folder;
hasChildren: boolean;
isExpanded: boolean;
}
const ITEM_HEIGHT = 32;
const INDENT_SIZE = 16;
interface FolderTreeBrowserProps {
fetchFolder: (folderId: string) => Promise<Folder>;
rootFolderQuery: UseQueryResult<Folder, Error>;
}
export const FolderTreeBrowser = ({ fetchFolder, rootFolderQuery }: FolderTreeBrowserProps) => {
const { currentFolderId, setFolderPath } = useFolderListFilters();
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loadedNodes, setLoadedNodes] = useState<Map<string, Folder[]>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
// Initialize root folder children when data is loaded
useEffect(() => {
if (rootFolderQuery.data?.children?.folders && !loadedNodes.has('0')) {
setLoadedNodes((prev) => {
const newMap = new Map(prev);
newMap.set('0', rootFolderQuery.data?.children?.folders || []);
return newMap;
});
}
}, [rootFolderQuery.data, loadedNodes]);
// Fetch folder when expanding a node
const fetchFolderChildren = useCallback(
async (folderId: string) => {
if (loadedNodes.has(folderId)) {
return;
}
try {
const result = await fetchFolder(folderId);
if (result?.children?.folders) {
setLoadedNodes((prev) => {
const newMap = new Map(prev);
const folders = result?.children?.folders || [];
newMap.set(folderId, folders);
return newMap;
});
} else {
// Even if no children, mark as loaded to avoid refetching
setLoadedNodes((prev) => {
const newMap = new Map(prev);
newMap.set(folderId, []);
return newMap;
});
}
} catch {
setLoadedNodes((prev) => {
const newMap = new Map(prev);
newMap.set(folderId, []);
return newMap;
});
}
},
[fetchFolder, loadedNodes],
);
// Get children for a folder
const getFolderChildren = useCallback(
(folder: Folder): Folder[] => {
// First check if we have explicitly loaded children in loadedNodes
const loaded = loadedNodes.get(folder.id);
if (loaded !== undefined) {
return loaded;
}
// Otherwise, use children from the folder object itself (if available)
// This handles cases where children came with the parent folder's response
return folder.children?.folders || [];
},
[loadedNodes],
);
// Build tree structure from root
const buildTree = useCallback(
(folder: Folder, depth: number = 0): TreeNode => {
const folderId = folder.id;
const isExpanded = expandedNodes.has(folderId);
const children = getFolderChildren(folder);
const hasChildren = children.length > 0;
const childrenLoaded =
loadedNodes.has(folderId) || (folder.children?.folders?.length ?? 0) > 0;
return {
childrenLoaded,
depth,
folder,
hasChildren,
isExpanded,
};
},
[expandedNodes, loadedNodes, getFolderChildren],
);
// Flatten tree to list for virtualization
const flattenedNodes = useMemo((): FlattenedNode[] => {
if (!rootFolderQuery.data) {
return [];
}
const result: FlattenedNode[] = [];
const rootFolder = rootFolderQuery.data;
const traverse = (
folder: Folder,
depth: number,
path: Array<{ id: string; name: string }> = [],
) => {
const node = buildTree(folder, depth);
const currentPath = [...path, { id: folder.id, name: folder.name }];
const isRoot = folder.id === '0';
// Skip the root folder (id: '0')
if (!isRoot) {
result.push({
depth: node.depth - 1,
folder: node.folder,
hasChildren: node.hasChildren,
isExpanded: node.isExpanded,
path: currentPath,
});
}
// For root folder, always traverse children
const shouldTraverseChildren = isRoot
? node.hasChildren
: node.isExpanded && node.hasChildren;
if (shouldTraverseChildren) {
const children = getFolderChildren(folder);
// Recursively traverse each child - this supports infinite nesting
children.forEach((child) => {
traverse(child, depth + 1, currentPath);
});
}
};
traverse(rootFolder, 0);
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootFolderQuery.data, expandedNodes, loadedNodes, buildTree, getFolderChildren]);
const toggleNode = useCallback(
(folderId: string, hasChildren: boolean, folder?: Folder) => {
setExpandedNodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(folderId)) {
newSet.delete(folderId);
} else {
newSet.add(folderId);
// Fetch children if not loaded and has children
// Check both loadedNodes and folder.children to determine if we need to fetch
const needsFetch =
hasChildren &&
!loadedNodes.has(folderId) &&
!(folder?.children?.folders && folder.children.folders.length > 0);
if (needsFetch) {
fetchFolderChildren(folderId);
}
}
return newSet;
});
},
[fetchFolderChildren, loadedNodes],
);
// Expand a node (doesn't collapse if already expanded)
const expandNode = useCallback(
(folderId: string, hasChildren: boolean, folder?: Folder) => {
setExpandedNodes((prev) => {
if (prev.has(folderId)) {
return prev;
}
// Expand the node
const newSet = new Set(prev);
newSet.add(folderId);
// Fetch children if not loaded and has children
const needsFetch =
hasChildren &&
!loadedNodes.has(folderId) &&
!(folder?.children?.folders && folder.children.folders.length > 0);
if (needsFetch) {
fetchFolderChildren(folderId);
}
return newSet;
});
},
[fetchFolderChildren, loadedNodes],
);
// Handle node click - toggle expand/collapse and set current folder
const handleNodeClick = useCallback(
(
folder: Folder,
path: Array<{ id: string; name: string }>,
isExpanded: boolean,
isCurrentFolder: boolean,
) => {
// Only toggle close if the node is expanded AND it's the current folder
if (isExpanded && isCurrentFolder) {
toggleNode(folder.id, true, folder);
} else if (!isExpanded) {
// Node is not expanded - check if we should expand it
const childrenLoaded = loadedNodes.has(folder.id);
const hasChildrenFromFolder = (folder.children?.folders?.length ?? 0) > 0;
// Determine if we should expand:
// - If children are loaded and empty, don't expand (we know it has no children)
// - Otherwise, try to expand/fetch (either has children or we don't know yet)
let shouldExpand = false;
let mightHaveChildren = false;
if (childrenLoaded) {
// Children are loaded - check if there are any
const loadedChildren = loadedNodes.get(folder.id) || [];
shouldExpand = loadedChildren.length > 0;
mightHaveChildren = loadedChildren.length > 0;
} else {
// Children not loaded yet - assume it might have children and try to expand
shouldExpand = true;
mightHaveChildren = true;
}
// Override with folder's children if available (from parent response)
if (hasChildrenFromFolder) {
shouldExpand = true;
mightHaveChildren = true;
}
if (shouldExpand) {
expandNode(folder.id, mightHaveChildren, folder);
}
}
// Set current folder path (full path from root to clicked folder)
// Skip the root folder (id: '0') from the path
const pathWithoutRoot = path.filter((item) => item.id !== '0');
setFolderPath(pathWithoutRoot);
},
[expandNode, loadedNodes, setFolderPath, toggleNode],
);
const rowProps = useMemo(
() => ({
currentFolderId,
data: flattenedNodes,
handleNodeClick,
toggleNode,
}),
[currentFolderId, flattenedNodes, handleNodeClick, toggleNode],
);
const [initialize, osInstance] = useOverlayScrollbars({
defer: false,
events: {
initialized(osInstance) {
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
},
},
options: {
overflow: { x: 'hidden', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
visibility: 'visible',
},
},
});
useEffect(() => {
const { current: container } = containerRef;
if (!container || !container.firstElementChild) {
return;
}
const viewport = container.firstElementChild as HTMLElement;
initialize({
elements: { viewport },
target: container,
});
return () => osInstance()?.destroy();
}, [initialize, osInstance]);
return (
<div className={styles.container} ref={containerRef}>
<List
rowComponent={RowComponent}
rowCount={flattenedNodes.length}
rowHeight={ITEM_HEIGHT}
rowProps={rowProps}
/>
</div>
);
};
const RowComponent = ({
currentFolderId,
data,
handleNodeClick,
index,
style,
toggleNode,
}: RowComponentProps<{
currentFolderId: null | string;
data: FlattenedNode[];
handleNodeClick: (
folder: Folder,
path: Array<{ id: string; name: string }>,
isExpanded: boolean,
isCurrentFolder: boolean,
) => void;
toggleNode: (folderId: string, hasChildren: boolean, folder?: Folder) => void;
}>) => {
const item = data[index];
const folderNameRef = useRef<HTMLSpanElement>(null);
const folderIconRef = useRef<HTMLDivElement>(null);
const expandIconRef = useRef<HTMLDivElement | null>(null);
const rowRef = useRef<HTMLDivElement>(null);
const [tooltipOffset, setTooltipOffset] = useState(0);
useLayoutEffect(() => {
if (!item) {
return;
}
const calculateOffset = () => {
if (rowRef.current && folderIconRef.current && expandIconRef.current) {
const width = rowRef.current.offsetWidth;
const paddingLeft = item.depth * INDENT_SIZE;
const folderIconWidth = folderIconRef.current.offsetWidth;
const expandIconWidth = expandIconRef.current.offsetWidth;
const itemPadding = 8;
setTooltipOffset(
-width + paddingLeft + folderIconWidth + expandIconWidth + itemPadding,
);
}
};
calculateOffset();
const handleResize = () => {
calculateOffset();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [item]);
const { isDragging, ref: dragRef } = useDragDrop<HTMLDivElement>({
drag: {
getId: () => (item ? [item.folder.id] : []),
getItem: () => (item ? [item.folder] : []),
itemType: LibraryItem.FOLDER,
operation: [DragOperation.ADD],
target: DragTarget.FOLDER,
},
isEnabled: !!item,
});
// Use dragRef for the element and also update rowRef for tooltip calculations
useEffect(() => {
if (dragRef && 'current' in dragRef && dragRef.current) {
rowRef.current = dragRef.current;
}
}, [dragRef]);
if (!item) {
return <div style={style} />;
}
const isActive = currentFolderId === item.folder.id;
const paddingLeft = item.depth * INDENT_SIZE;
const handleExpandClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleNode(item.folder.id, item.hasChildren, item.folder);
};
const handleRowClick = () => {
handleNodeClick(item.folder, item.path, item.isExpanded, isActive);
};
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
ContextMenuController.call({
cmd: {
items: [item.folder],
type: LibraryItem.FOLDER,
},
event: e,
});
};
return (
<Tooltip
classNames={{
tooltip: styles.tooltip,
}}
label={item.folder.name}
offset={tooltipOffset}
openDelay={0}
position="right"
withArrow={false}
>
<div
className={clsx(styles.row, {
[styles.active]: isActive,
[styles.dragging]: isDragging,
})}
onClick={handleRowClick}
onContextMenu={handleContextMenu}
ref={dragRef}
style={{
...style,
paddingLeft: `${paddingLeft}px`,
}}
>
<div className={styles.rowContent}>
{item.hasChildren ? (
<div className={styles.expandIconContainer} ref={expandIconRef}>
<Icon
className={clsx(styles.expandIcon, {
[styles.expanded]: item.isExpanded,
})}
icon="arrowRightS"
onClick={handleExpandClick}
size="sm"
/>
</div>
) : (
<div className={styles.expandIconPlaceholder} ref={expandIconRef} />
)}
<div className={styles.folderIconContainer} ref={folderIconRef}>
<Icon className={styles.folderIcon} icon="folder" size="md" />
</div>
<span className={styles.folderName} ref={folderNameRef}>
{item.folder.name}
</span>
</div>
</div>
</Tooltip>
);
};
@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { parseJsonParam, setJsonSearchParam } from '/@/renderer/utils/query-params';
import { SongListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export type FolderPathItem = {
id: string;
name: string;
};
export const useFolderListFilters = () => {
const { sortBy } = useSortByFilter<SongListSort>(null, ItemListKey.SONG);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.SONG);
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
const [searchParams, setSearchParams] = useSearchParams();
const folderPath = useMemo(() => {
const path = parseJsonParam<FolderPathItem[]>(searchParams, FILTER_KEYS.FOLDER.FOLDER_PATH);
return path || [];
}, [searchParams]);
const setFolderPath = (path: FolderPathItem[]) => {
setSearchParams(
(prev) => {
const newParams = setJsonSearchParam(prev, FILTER_KEYS.FOLDER.FOLDER_PATH, path);
return newParams;
},
{ replace: false },
);
};
// Navigate to a folder (adds to path)
const navigateToFolder = (folderId: string, folderName: string) => {
setFolderPath([...folderPath, { id: folderId, name: folderName }]);
};
// Navigate back to a specific folder in the path (truncates path)
const navigateToPathIndex = (index: number) => {
setFolderPath(folderPath.slice(0, index + 1));
};
// Get current folder ID (last item in path, or '0' for root)
const currentFolderId = useMemo(() => {
return folderPath.length > 0 ? folderPath[folderPath.length - 1].id : '0';
}, [folderPath]);
const query = {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
};
return {
currentFolderId,
folderPath,
navigateToFolder,
navigateToPathIndex,
query,
setFolderPath,
setSearchTerm,
};
};
@@ -0,0 +1,45 @@
import { useMemo, useState } from 'react';
import { ListContext } from '/@/renderer/context/list-context';
import { FolderListContent } from '/@/renderer/features/folders/components/folder-list-content';
import { FolderListHeader } from '/@/renderer/features/folders/components/folder-list-header';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { ItemListKey } from '/@/shared/types/types';
const FolderListRoute = () => {
const pageKey = ItemListKey.SONG;
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const providerValue = useMemo(() => {
return {
id: undefined,
itemCount,
pageKey,
setItemCount,
};
}, [itemCount, pageKey, setItemCount]);
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<FolderListHeader />
<ListWithSidebarContainer>
<FolderListContent />
</ListWithSidebarContainer>
</ListContext.Provider>
</AnimatedPage>
);
};
const FolderListRouteWithBoundary = () => {
return (
<PageErrorBoundary>
<FolderListRoute />
</PageErrorBoundary>
);
};
export default FolderListRouteWithBoundary;