diff --git a/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx index 66beb5422..c61040050 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx @@ -12,6 +12,7 @@ type ItemCardProps = { item: Item; showFolderPath?: boolean; searchTerm?: string; + currentFolderPath?: string[] | null; }; /** @@ -21,7 +22,7 @@ type ItemCardProps = { * It allows the user to navigate to the item details page when clicked. * */ -const ItemCard: React.FC = ({ item, showFolderPath = false, searchTerm = '' }) => { +const ItemCard: React.FC = ({ item, showFolderPath = false, searchTerm = '', currentFolderPath = null }) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -66,14 +67,36 @@ const ItemCard: React.FC = ({ item, showFolderPath = false, searc }; /** - * Get formatted folder path for display, truncating if needed. + * Get formatted folder path for display, relative to current folder if applicable. + * When searching inside a folder, shows path relative to that folder instead of full path from root. + * @param folderPath - Full folder path from root as array (e.g., ["Work", "Projects", "Sub"]) + * @param currentFolderPath - Current folder's full path as array (e.g., ["Work", "Projects"]) + * @returns Relative path string with truncation if needed */ - const getFormattedFolderPath = (folderPath: string): string => { - if (!folderPath) { + const getFormattedFolderPath = (folderPath: string[], currentFolderPath?: string[]): string => { + if (!folderPath || folderPath.length === 0) { return ''; } - const segments = folderPath.split(' > '); - const truncated = truncateFolderPath(segments, 3); + + let segmentsToDisplay = folderPath; + + // If we're inside a folder while searching, show relative path + if (currentFolderPath && currentFolderPath.length > 0) { + // Check if item is in a subfolder of current folder + const isInSubfolder = currentFolderPath.every((segment, index) => folderPath[index] === segment); + + if (isInSubfolder) { + if (folderPath.length === currentFolderPath.length) { + // Item is directly in current folder - don't show any path + return ''; + } + // Item is in a subfolder - show relative path (remove current folder prefix) + segmentsToDisplay = folderPath.slice(currentFolderPath.length); + } + // else: Item is in a different branch - show full path + } + + const truncated = truncateFolderPath(segmentsToDisplay, 3); return truncated.join(' > '); }; @@ -93,11 +116,16 @@ const ItemCard: React.FC = ({ item, showFolderPath = false, searc

- {showFolderPath && item.FolderPath ? ( + {showFolderPath && item.FolderPath && item.FolderPath.length > 0 ? ( <> - - {getFormattedFolderPath(item.FolderPath)} >{' '} - + {(() : React.ReactNode => { + const relativePath = getFormattedFolderPath(item.FolderPath, currentFolderPath || undefined); + return relativePath ? ( + ')}> + {relativePath} >{' '} + + ) : null; + })()} {getItemName(item)} ) : ( 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 1e45c5b48..ce0db931c 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 } from '@/utils/folderUtils'; +import { canHaveSubfolders, getFolderPath } from '@/utils/folderUtils'; import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; @@ -157,6 +157,16 @@ const ItemsList: React.FC = () => { return folder?.Name ?? null; }, [currentFolderId, dbContext?.sqliteClient, folderRefreshKey]); + // Get current folder's full path (for relative path computation in search results) + const currentFolderPath = useMemo(() => { + if (!currentFolderId || !dbContext?.sqliteClient) { + return null; + } + const folders = dbContext.sqliteClient.folders.getAll(); + const path = getFolderPath(currentFolderId, folders); + return path.length > 0 ? path : null; + }, [currentFolderId, dbContext?.sqliteClient]); + /** * Loading state with minimum duration for more fluid UX. */ @@ -580,8 +590,21 @@ const ItemsList: React.FC = () => { const filteredItems = items.filter((item: Item) => { // Filter by current folder (if in folder view) if (currentFolderId !== null) { - if (item.FolderId !== currentFolderId) { - return false; + // When searching inside a folder, include items in subfolders too + if (searchTerm) { + // Get all child folder IDs recursively + const childFolderIds = getAllChildFolderIds(currentFolderId); + const allFolderIds = [currentFolderId, ...childFolderIds]; + + // Item must be in current folder or any subfolder + if (!item.FolderId || !allFolderIds.includes(item.FolderId)) { + return false; + } + } else { + // When not searching, only show direct items (not items in subfolders) + if (item.FolderId !== currentFolderId) { + return false; + } } } else if (!searchTerm && showFolders) { /* @@ -1120,6 +1143,7 @@ const ItemsList: React.FC = () => { item={item} showFolderPath={!!searchTerm && !!item.FolderPath} searchTerm={searchTerm} + currentFolderPath={currentFolderPath} /> ))} diff --git a/apps/browser-extension/src/utils/db/mappers/ItemMapper.ts b/apps/browser-extension/src/utils/db/mappers/ItemMapper.ts index 88f464e37..1c6e97fd2 100644 --- a/apps/browser-extension/src/utils/db/mappers/ItemMapper.ts +++ b/apps/browser-extension/src/utils/db/mappers/ItemMapper.ts @@ -13,7 +13,6 @@ export type ItemRow = { Name: string; ItemType: string; FolderId: string | null; - FolderPath: string | null; Logo: Uint8Array | null; HasPasskey: number; HasAttachment: number; @@ -42,12 +41,14 @@ export class ItemMapper { * @param row - Raw item row from database * @param fields - Processed fields for this item * @param tags - Tags for this item + * @param folderPath - Computed folder path array (optional) * @returns Item object */ public static mapRow( row: ItemRow, fields: ItemField[] = [], - tags: ItemTagRef[] = [] + tags: ItemTagRef[] = [], + folderPath?: string[] ): Item { return { Id: row.Id, @@ -55,7 +56,7 @@ export class ItemMapper { ItemType: row.ItemType as ItemType, Logo: row.Logo ?? undefined, FolderId: row.FolderId, - FolderPath: row.FolderPath || null, + FolderPath: folderPath, Tags: tags, Fields: fields, HasPasskey: row.HasPasskey === 1, @@ -71,17 +72,20 @@ export class ItemMapper { * @param rows - Raw item rows from database * @param fieldsByItem - Map of ItemId to array of fields * @param tagsByItem - Map of ItemId to array of tags + * @param folderPathsByFolderId - Map of FolderId to folder path array (optional) * @returns Array of Item objects */ public static mapRows( rows: ItemRow[], fieldsByItem: Map, - tagsByItem: Map + tagsByItem: Map, + folderPathsByFolderId?: Map ): Item[] { return rows.map(row => this.mapRow( row, fieldsByItem.get(row.Id) || [], - tagsByItem.get(row.Id) || [] + tagsByItem.get(row.Id) || [], + row.FolderId && folderPathsByFolderId ? folderPathsByFolderId.get(row.FolderId) : undefined )); } @@ -124,11 +128,13 @@ export class ItemMapper { * Map a single item row for recently deleted items (includes DeletedAt). * @param row - Raw item row with DeletedAt * @param fields - Processed fields for this item + * @param folderPath - Computed folder path array (optional) * @returns Item object with DeletedAt */ public static mapDeletedItemRow( row: ItemRow & { DeletedAt: string }, - fields: ItemField[] = [] + fields: ItemField[] = [], + folderPath?: string[] ): ItemWithDeletedAt { return { Id: row.Id, @@ -136,7 +142,7 @@ export class ItemMapper { ItemType: row.ItemType as ItemType, Logo: row.Logo ? new Uint8Array(row.Logo) : undefined, FolderId: row.FolderId, - FolderPath: row.FolderPath, + FolderPath: folderPath, DeletedAt: row.DeletedAt, HasPasskey: row.HasPasskey === 1, HasAttachment: row.HasAttachment === 1, diff --git a/apps/browser-extension/src/utils/db/queries/ItemQueries.ts b/apps/browser-extension/src/utils/db/queries/ItemQueries.ts index 3455daabf..b42998f88 100644 --- a/apps/browser-extension/src/utils/db/queries/ItemQueries.ts +++ b/apps/browser-extension/src/utils/db/queries/ItemQueries.ts @@ -5,40 +5,14 @@ export class ItemQueries { /** * Base SELECT for items with common fields. - * Includes LEFT JOIN to Logos and recursive CTE for full folder paths. - * Builds hierarchical folder paths like "Work > Projects > AliasVault". + * Includes LEFT JOIN to Logos. Folder paths are computed in the repository layer. */ public static readonly BASE_SELECT = ` - WITH RECURSIVE FolderPath AS ( - -- Base case: root folders (no parent) - SELECT - Id, - Name, - ParentFolderId, - Name as Path, - 0 as Depth - FROM Folders - WHERE ParentFolderId IS NULL AND IsDeleted = 0 - - UNION ALL - - -- Recursive case: child folders - SELECT - f.Id, - f.Name, - f.ParentFolderId, - fp.Path || ' > ' || f.Name as Path, - fp.Depth + 1 as Depth - FROM Folders f - INNER JOIN FolderPath fp ON f.ParentFolderId = fp.Id - WHERE f.IsDeleted = 0 AND fp.Depth < 10 - ) SELECT DISTINCT i.Id, i.Name, i.ItemType, i.FolderId, - fp.Path as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, @@ -46,8 +20,7 @@ export class ItemQueries { i.CreatedAt, i.UpdatedAt FROM Items i - LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN FolderPath fp ON i.FolderId = fp.Id`; + LEFT JOIN Logos l ON i.LogoId = l.Id`; /** * Get all active items (not deleted, not in trash). @@ -78,8 +51,6 @@ export class ItemQueries { /** * Get all recently deleted items (in trash). - * Note: Trashed items have FolderId = NULL (severed from folder structure), - * so we don't need the recursive CTE for folder paths. */ public static readonly GET_RECENTLY_DELETED = ` SELECT @@ -87,7 +58,6 @@ export class ItemQueries { i.Name, i.ItemType, i.FolderId, - NULL as FolderPath, l.FileData as Logo, CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, diff --git a/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts b/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts index 6db7959f5..b5f40d892 100644 --- a/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts +++ b/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts @@ -1,5 +1,6 @@ import type { Item, ItemField, Attachment, TotpCode, FieldHistory } from '@/utils/dist/core/models/vault'; import { FieldKey, MAX_FIELD_HISTORY_RECORDS } from '@/utils/dist/core/models/vault'; +import { getFolderPath } from '@/utils/folderUtils'; import { BaseRepository, type IDatabaseClient } from '../BaseRepository'; import { FieldMapper, type FieldRow } from '../mappers/FieldMapper'; @@ -13,6 +14,7 @@ import { AttachmentQueries } from '../queries/ItemQueries'; +import type { Folder } from './FolderRepository'; import type { LogoRepository } from './LogoRepository'; /** @@ -32,6 +34,42 @@ export class ItemRepository extends BaseRepository { super(client); } + /** + * Build folder paths for all folders using the shared utility. + * Returns a map of FolderId -> path array. + * @returns Map of folder ID to path array + */ + private buildFolderPaths(): Map { + const folderPathMap = new Map(); + + try { + // Get all folders from database + const folders = this.client.executeQuery( + 'SELECT Id, Name, ParentFolderId, Weight FROM Folders WHERE IsDeleted = 0' + ); + + if (folders.length === 0) { + return folderPathMap; + } + + // Use shared utility to build paths for all folders + for (const folder of folders) { + const path = getFolderPath(folder.Id, folders); + if (path.length > 0) { + folderPathMap.set(folder.Id, path); + } + } + + return folderPathMap; + } catch (error) { + // Folders table may not exist in older vault versions + if (error instanceof Error && error.message.includes('no such table')) { + return folderPathMap; + } + throw error; + } + } + /** * Fetch all active items with their dynamic fields and tags. * @returns Array of Item objects (empty array if Items table doesn't exist yet) @@ -68,7 +106,10 @@ export class ItemRepository extends BaseRepository { ); const tagsByItem = ItemMapper.groupTagsByItem(tagRows); - return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem); + // Build folder paths + const folderPaths = this.buildFolderPaths(); + + return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem, folderPaths); } /** @@ -96,7 +137,14 @@ export class ItemRepository extends BaseRepository { ); const tags = ItemMapper.mapTagRows(tagRows); - return ItemMapper.mapRow(results[0], fields, tags); + // Get folder path if item is in a folder + let folderPath: string[] | undefined; + if (results[0].FolderId) { + const folderPaths = this.buildFolderPaths(); + folderPath = folderPaths.get(results[0].FolderId); + } + + return ItemMapper.mapRow(results[0], fields, tags, folderPath); } /** @@ -339,7 +387,14 @@ export class ItemRepository extends BaseRepository { ); const fieldsByItem = FieldMapper.processFieldRows(fieldRows); - return itemRows.map(row => ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || [])); + // Build folder paths + const folderPaths = this.buildFolderPaths(); + + return itemRows.map(row => ItemMapper.mapDeletedItemRow( + row, + fieldsByItem.get(row.Id) || [], + row.FolderId ? folderPaths.get(row.FolderId) : undefined + )); } /** diff --git a/apps/browser-extension/src/utils/dist/core/models/vault/index.d.ts b/apps/browser-extension/src/utils/dist/core/models/vault/index.d.ts index 24dfb43c2..cd45b7219 100644 --- a/apps/browser-extension/src/utils/dist/core/models/vault/index.d.ts +++ b/apps/browser-extension/src/utils/dist/core/models/vault/index.d.ts @@ -272,7 +272,7 @@ type Item = { ItemType: ItemType; Logo?: Uint8Array | number[]; FolderId?: string | null; - FolderPath?: string | null; + FolderPath?: string[]; Tags?: ItemTagRef[]; Fields: ItemField[]; HasPasskey?: boolean; diff --git a/apps/browser-extension/src/utils/dist/core/models/webapi/index.d.ts b/apps/browser-extension/src/utils/dist/core/models/webapi/index.d.ts index f25577f53..f83fab78c 100644 --- a/apps/browser-extension/src/utils/dist/core/models/webapi/index.d.ts +++ b/apps/browser-extension/src/utils/dist/core/models/webapi/index.d.ts @@ -300,6 +300,7 @@ type DeleteAccountInitiateResponse = { serverEphemeral: string; encryptionType: string; encryptionSettings: string; + srpIdentity: string; }; /** diff --git a/apps/mobile-app/utils/dist/core/models/vault/index.d.ts b/apps/mobile-app/utils/dist/core/models/vault/index.d.ts index 24dfb43c2..cd45b7219 100644 --- a/apps/mobile-app/utils/dist/core/models/vault/index.d.ts +++ b/apps/mobile-app/utils/dist/core/models/vault/index.d.ts @@ -272,7 +272,7 @@ type Item = { ItemType: ItemType; Logo?: Uint8Array | number[]; FolderId?: string | null; - FolderPath?: string | null; + FolderPath?: string[]; Tags?: ItemTagRef[]; Fields: ItemField[]; HasPasskey?: boolean; diff --git a/apps/mobile-app/utils/dist/core/models/webapi/index.d.ts b/apps/mobile-app/utils/dist/core/models/webapi/index.d.ts index f25577f53..f83fab78c 100644 --- a/apps/mobile-app/utils/dist/core/models/webapi/index.d.ts +++ b/apps/mobile-app/utils/dist/core/models/webapi/index.d.ts @@ -300,6 +300,7 @@ type DeleteAccountInitiateResponse = { serverEphemeral: string; encryptionType: string; encryptionSettings: string; + srpIdentity: string; }; /** diff --git a/core/models/src/vault/Item.ts b/core/models/src/vault/Item.ts index 29f63f563..f44b5fa60 100644 --- a/core/models/src/vault/Item.ts +++ b/core/models/src/vault/Item.ts @@ -22,7 +22,7 @@ export type Item = { ItemType: ItemType; Logo?: Uint8Array | number[]; FolderId?: string | null; - FolderPath?: string | null; + FolderPath?: string[]; Tags?: ItemTagRef[]; Fields: ItemField[]; HasPasskey?: boolean;