diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index ce0db931c..268d7b569 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -24,7 +24,7 @@ import type { Folder } from '@/utils/db/repositories/FolderRepository'; import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsRepository'; import type { Item, ItemType } from '@/utils/dist/core/models/vault'; import { ItemTypes } from '@/utils/dist/core/models/vault'; -import { canHaveSubfolders, getFolderPath } from '@/utils/folderUtils'; +import { canHaveSubfolders, getDescendantFolderIds, getFolderPath, getRecursiveItemCount } from '@/utils/folderUtils'; import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; @@ -532,12 +532,12 @@ const ItemsList: React.FC = () => { * @param folderId - The folder ID to count items for * @returns Total count of items in this folder and all descendant folders */ - const getRecursiveItemCount = (folderId: string): number => { + const getRecursiveItemCountLocal = (folderId: string): number => { // Start with direct items in this folder let count = directFolderCounts.get(folderId) || 0; // Add counts from all child folders recursively - const childFolderIds = getAllChildFolderIds(folderId); + const childFolderIds = getDescendantFolderIds(folderId, allFolders); for (const childId of childFolderIds) { count += directFolderCounts.get(childId) || 0; } @@ -549,41 +549,12 @@ const ItemsList: React.FC = () => { const result = relevantFolders.map((folder: Folder) => ({ id: folder.Id, name: folder.Name, - itemCount: getRecursiveItemCount(folder.Id) + itemCount: getRecursiveItemCountLocal(folder.Id) })).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name)); return result; }; - /** - * Get all child folder IDs recursively for a given folder. - * @param folderId - The parent folder ID to get children for - * @returns Array of all descendant folder IDs - */ - const getAllChildFolderIds = useCallback((folderId: string): string[] => { - if (!dbContext?.sqliteClient) { - return []; - } - - const allFolders = dbContext.sqliteClient.folders.getAll(); - const childIds: string[] = []; - - /** - * Recursively find all child folders. - * @param parentId - The parent folder ID - */ - const findChildren = (parentId: string): void => { - const children = allFolders.filter(f => f.ParentFolderId === parentId); - for (const child of children) { - childIds.push(child.Id); - findChildren(child.Id); // Recursively find descendants - } - }; - - findChildren(folderId); - return childIds; - }, [dbContext]); - /** * Filter items based on current view (folder, search, filter type) */ @@ -593,7 +564,8 @@ const ItemsList: React.FC = () => { // When searching inside a folder, include items in subfolders too if (searchTerm) { // Get all child folder IDs recursively - const childFolderIds = getAllChildFolderIds(currentFolderId); + const allFolders = dbContext?.sqliteClient?.folders.getAll() || []; + const childFolderIds = getDescendantFolderIds(currentFolderId, allFolders); const allFolderIds = [currentFolderId, ...childFolderIds]; // Item must be in current folder or any subfolder @@ -689,19 +661,13 @@ const ItemsList: React.FC = () => { * Used for the delete folder modal to show accurate count. */ const totalItemCountInFolderTree = useMemo(() => { - if (!currentFolderId) { + if (!currentFolderId || !dbContext?.sqliteClient) { return filteredItems.length; } - // Get all child folder IDs - const childFolderIds = getAllChildFolderIds(currentFolderId); - const allFolderIds = [currentFolderId, ...childFolderIds]; - - // Count items in current folder and all child folders - return items.filter(item => - item.FolderId && allFolderIds.includes(item.FolderId) - ).length; - }, [currentFolderId, items, getAllChildFolderIds, filteredItems.length]); + const allFolders = dbContext.sqliteClient.folders.getAll(); + return getRecursiveItemCount(currentFolderId, items, allFolders); + }, [currentFolderId, items, dbContext?.sqliteClient, filteredItems.length]); /** * Check if the current folder can have subfolders (not at max depth). diff --git a/apps/browser-extension/src/utils/folderUtils.ts b/apps/browser-extension/src/utils/folderUtils.ts index 9ad535c77..3f5c14c1b 100644 --- a/apps/browser-extension/src/utils/folderUtils.ts +++ b/apps/browser-extension/src/utils/folderUtils.ts @@ -296,3 +296,23 @@ export function getDescendantFolderIds(folderId: string, folders: Folder[]): str traverse(folderId); return descendants; } + +/** + * Get total count of items in a folder and all its subfolders. + * @param folderId - The folder ID to count items for + * @param allItems - All items in the vault + * @param allFolders - All folders in the vault + * @returns Total item count including subfolders + */ +export function getRecursiveItemCount( + folderId: string, + allItems: Array<{ FolderId?: string | null }>, + allFolders: Folder[] +): number { + // Get all descendant folder IDs + const descendantIds = getDescendantFolderIds(folderId, allFolders); + const allFolderIds = [folderId, ...descendantIds]; + + // Count items in current folder and all descendants + return allItems.filter(item => item.FolderId && allFolderIds.includes(item.FolderId)).length; +} diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx index 48b784432..11bd17aa3 100644 --- a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -12,6 +12,7 @@ import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsReposi import type { Item, ItemType } from '@/utils/dist/core/models/vault'; import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; +import { canHaveSubfolders, getRecursiveItemCount } from '@/utils/folderUtils'; import { HapticsUtility } from '@/utils/HapticsUtility'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; @@ -89,6 +90,7 @@ export default function FolderViewScreen(): React.ReactNode { const [folder, setFolder] = useState(null); const [subfolders, setSubfolders] = useState([]); const [canCreateSubfolder, setCanCreateSubfolder] = useState(false); + const [allFolders, setAllFolders] = useState([]); // No minimum loading delay for folder view since data is already in memory const [isLoadingItems, setIsLoadingItems] = useState(false); const [refreshing, setRefreshing] = useMinDurationLoading(false, 200); @@ -186,38 +188,16 @@ export default function FolderViewScreen(): React.ReactNode { const sortedItems = useSortedItems(filteredItems, sortOrder); /** - * Get folder depth in the hierarchy. + * Calculate total item count including all items in current folder and all child folders recursively. + * Used for the delete folder modal to show accurate count. */ - function getFolderDepth(folderId: string | null, folders: Folder[]): number | null { - if (!folderId) { - return null; + const totalItemCountInFolderTree = useMemo(() => { + if (!folderId || allFolders.length === 0) { + return 0; } - const folderItem = folders.find(f => f.Id === folderId); - if (!folderItem) { - return null; - } - - let depth = 0; - let currentId: string | null = folderId; - - // Traverse up to root, counting levels - while (currentId) { - const current = folders.find(f => f.Id === currentId); - if (!current || !current.ParentFolderId) { - break; - } - depth++; - currentId = current.ParentFolderId; - - // Prevent infinite loops - if (depth > 4) { - break; - } - } - - return depth; - } + return getRecursiveItemCount(folderId, itemsList, allFolders); + }, [folderId, allFolders, itemsList]); /** * Load items in this folder, subfolders, and folder details. @@ -244,37 +224,17 @@ export default function FolderViewScreen(): React.ReactNode { // Get subfolders (direct children only) const childFolders = folders.filter((f: Folder) => f.ParentFolderId === folderId); - /** - * Calculate recursive item count for a folder including all subfolders. - * @param parentFolderId - The parent folder ID to count items for - * @returns Total count of items in the folder and all subfolders - */ - const getRecursiveItemCount = (parentFolderId: string): number => { - // Get items directly in this folder - const directItems = items.filter((item: Item) => item.FolderId === parentFolderId); - - // Get all child folders - const children = folders.filter((f: Folder) => f.ParentFolderId === parentFolderId); - - // Recursively count items in child folders - const childItemCount = children.reduce((count, child) => { - return count + getRecursiveItemCount(child.Id); - }, 0); - - return directItems.length + childItemCount; - }; - const subfoldersWithCounts: FolderWithCount[] = childFolders.map((f) => ({ id: f.Id, name: f.Name, - itemCount: getRecursiveItemCount(f.Id), + itemCount: getRecursiveItemCount(f.Id, items, folders), })); setSubfolders(subfoldersWithCounts); + setAllFolders(folders); // Calculate if we can create subfolders (check depth) - const depth = getFolderDepth(folderId, folders); - setCanCreateSubfolder(depth !== null && depth < 4); + setCanCreateSubfolder(canHaveSubfolders(folderId, folders)); setSortOrder(savedSortOrder); setIsLoadingItems(false); @@ -1034,7 +994,7 @@ export default function FolderViewScreen(): React.ReactNode { onClose={() => setShowDeleteFolderModal(false)} onDeleteFolderOnly={handleDeleteFolderOnly} onDeleteFolderAndContents={handleDeleteFolderAndContents} - itemCount={itemsList.length} + itemCount={totalItemCountInFolderTree} /> ); diff --git a/apps/mobile-app/components/folders/FolderBreadcrumb.tsx b/apps/mobile-app/components/folders/FolderBreadcrumb.tsx index 23cc6e3c0..2a6f6982a 100644 --- a/apps/mobile-app/components/folders/FolderBreadcrumb.tsx +++ b/apps/mobile-app/components/folders/FolderBreadcrumb.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import type { Folder } from '@/utils/db/repositories/FolderRepository'; +import { getFolderIdPath, getFolderPath } from '@/utils/folderUtils'; import { useColors } from '@/hooks/useColorScheme'; import { useDb } from '@/context/DbContext'; @@ -32,64 +32,6 @@ type FolderBreadcrumbProps = { excludeCurrentFolder?: boolean; }; -/** - * Get the full path of folder names from root to the specified folder. - * @param folderId - The folder ID - * @param folders - Flat array of all folders - * @returns Array of folder names from root to current folder, or empty array if not found - */ -function getFolderPath(folderId: string | null, folders: Folder[]): string[] { - if (!folderId) { - return []; - } - - const path: string[] = []; - let currentId: string | null = folderId; - let iterations = 0; - - // Build path by traversing up to root - while (currentId && iterations < 5) { - const folder = folders.find(f => f.Id === currentId); - if (!folder) { - break; - } - path.unshift(folder.Name); // Add to beginning of array - currentId = folder.ParentFolderId; - iterations++; - } - - return path; -} - -/** - * Get the full path of folder IDs from root to the specified folder. - * @param folderId - The folder ID - * @param folders - Flat array of all folders - * @returns Array of folder IDs from root to current folder, or empty array if not found - */ -function getFolderIdPath(folderId: string | null, folders: Folder[]): string[] { - if (!folderId) { - return []; - } - - const path: string[] = []; - let currentId: string | null = folderId; - let iterations = 0; - - // Build path by traversing up to root - while (currentId && iterations < 5) { - const folder = folders.find(f => f.Id === currentId); - if (!folder) { - break; - } - path.unshift(folder.Id); // Add to beginning of array - currentId = folder.ParentFolderId; - iterations++; - } - - return path; -} - /** * Displays a breadcrumb navigation trail for folder hierarchy. * Shows the path to the current location, with optional exclusion of current folder. diff --git a/apps/mobile-app/utils/folderUtils.ts b/apps/mobile-app/utils/folderUtils.ts new file mode 100644 index 000000000..7d8478160 --- /dev/null +++ b/apps/mobile-app/utils/folderUtils.ts @@ -0,0 +1,180 @@ +import type { Folder } from './db/repositories/FolderRepository'; + +/** + * Maximum allowed folder nesting depth. + * Structure: Root (0) > Level 1 (1) > Level 2 (2) > Level 3 (3) > Level 4 (4) + * Folders at depth 4 cannot have subfolders. + */ +export const MAX_FOLDER_DEPTH = 4; + +/** + * Get folder depth in the hierarchy. + * @param folderId - The folder ID to check + * @param folders - Flat array of all folders + * @returns Depth (0 = root, 1 = one level deep, etc.) or null if folder not found + */ +export function getFolderDepth(folderId: string, folders: Folder[]): number | null { + const folder = folders.find(f => f.Id === folderId); + if (!folder) { + return null; + } + + let depth = 0; + let currentId: string | null = folderId; + + // Traverse up to root, counting levels + while (currentId) { + const current = folders.find(f => f.Id === currentId); + if (!current || !current.ParentFolderId) { + break; + } + depth++; + currentId = current.ParentFolderId; + + // Prevent infinite loops + if (depth > MAX_FOLDER_DEPTH) { + break; + } + } + + return depth; +} + +/** + * Get the full path of folder names from root to the specified folder. + * @param folderId - The folder ID + * @param folders - Flat array of all folders + * @returns Array of folder names from root to current folder, or empty array if not found + */ +export function getFolderPath(folderId: string | null, folders: Folder[]): string[] { + if (!folderId) { + return []; + } + + const path: string[] = []; + let currentId: string | null = folderId; + let iterations = 0; + + // Build path by traversing up to root + while (currentId && iterations < MAX_FOLDER_DEPTH + 1) { + const folder = folders.find(f => f.Id === currentId); + if (!folder) { + break; + } + path.unshift(folder.Name); // Add to beginning of array + currentId = folder.ParentFolderId; + iterations++; + } + + return path; +} + +/** + * Get the full path of folder IDs from root to the specified folder. + * @param folderId - The folder ID + * @param folders - Flat array of all folders + * @returns Array of folder IDs from root to current folder, or empty array if not found + */ +export function getFolderIdPath(folderId: string | null, folders: Folder[]): string[] { + if (!folderId) { + return []; + } + + const path: string[] = []; + let currentId: string | null = folderId; + let iterations = 0; + + // Build path by traversing up to root + while (currentId && iterations < MAX_FOLDER_DEPTH + 1) { + const folder = folders.find(f => f.Id === currentId); + if (!folder) { + break; + } + path.unshift(folder.Id); // Add to beginning of array + currentId = folder.ParentFolderId; + iterations++; + } + + return path; +} + +/** + * Format folder path for display with separator. + * @param pathSegments - Array of folder names + * @param separator - Separator string (default: " > ") + * @returns Formatted folder path string + */ +export function formatFolderPath( + pathSegments: string[], + separator: string = ' > ' +): string { + return pathSegments.join(separator); +} + +/** + * Check if a folder can have subfolders (not at max depth). + * @param folderId - The folder ID to check + * @param folders - Flat array of all folders + * @returns True if folder can have children, false otherwise + */ +export function canHaveSubfolders(folderId: string, folders: Folder[]): boolean { + const depth = getFolderDepth(folderId, folders); + return depth !== null && depth < MAX_FOLDER_DEPTH; +} + +/** + * Get all descendant folder IDs (children, grandchildren, etc.). + * @param folderId - The parent folder ID + * @param folders - Flat array of all folders + * @returns Array of descendant folder IDs + */ +export function getDescendantFolderIds(folderId: string, folders: Folder[]): string[] { + const descendants: string[] = []; + + /** + * Traverse a folder tree and get all descendant folder IDs. + */ + const traverse = (parentId: string): void => { + folders + .filter(f => f.ParentFolderId === parentId) + .forEach(child => { + descendants.push(child.Id); + traverse(child.Id); + }); + }; + + traverse(folderId); + return descendants; +} + +/** + * Get all direct child folder IDs. + * @param parentFolderId - The parent folder ID (null for root) + * @param folders - Flat array of all folders + * @returns Array of direct child folder IDs + */ +export function getDirectChildFolderIds(parentFolderId: string | null, folders: Folder[]): string[] { + return folders + .filter(f => f.ParentFolderId === parentFolderId) + .map(f => f.Id); +} + +/** + * Get total count of items in a folder and all its subfolders. + * @param folderId - The folder ID to count items for + * @param allItems - All items in the vault + * @param allFolders - All folders in the vault + * @returns Total item count including subfolders + */ +export function getRecursiveItemCount( + folderId: string, + allItems: Array<{ FolderId?: string | null }>, + allFolders: Folder[] +): number { + // Get all descendant folder IDs + const descendantIds = getDescendantFolderIds(folderId, allFolders); + const allFolderIds = [folderId, ...descendantIds]; + + // Count items in current folder and all descendants + return allItems.filter(item => item.FolderId && allFolderIds.includes(item.FolderId)).length; +}