Update search result display when searching inside subfolders (#1695)

This commit is contained in:
Leendert de Borst
2026-03-27 08:38:06 +01:00
parent 3c36917a5a
commit a7cc62dc71
10 changed files with 143 additions and 58 deletions

View File

@@ -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<ItemCardProps> = ({ item, showFolderPath = false, searchTerm = '' }) => {
const ItemCard: React.FC<ItemCardProps> = ({ item, showFolderPath = false, searchTerm = '', currentFolderPath = null }) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -66,14 +67,36 @@ const ItemCard: React.FC<ItemCardProps> = ({ 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<ItemCardProps> = ({ item, showFolderPath = false, searc
<div className="text-left flex-1">
<div className="flex items-center gap-1.5">
<p className="font-medium text-gray-900 dark:text-white">
{showFolderPath && item.FolderPath ? (
{showFolderPath && item.FolderPath && item.FolderPath.length > 0 ? (
<>
<span className="text-gray-500 dark:text-gray-400 text-sm" title={item.FolderPath}>
{getFormattedFolderPath(item.FolderPath)} &gt;{' '}
</span>
{(() : React.ReactNode => {
const relativePath = getFormattedFolderPath(item.FolderPath, currentFolderPath || undefined);
return relativePath ? (
<span className="text-gray-500 dark:text-gray-400 text-sm" title={item.FolderPath.join(' > ')}>
{relativePath} &gt;{' '}
</span>
) : null;
})()}
{getItemName(item)}
</>
) : (

View File

@@ -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}
/>
))}
</ul>

View File

@@ -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<string, ItemField[]>,
tagsByItem: Map<string, ItemTagRef[]>
tagsByItem: Map<string, ItemTagRef[]>,
folderPathsByFolderId?: Map<string, string[]>
): 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,

View File

@@ -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,

View File

@@ -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<string, string[]> {
const folderPathMap = new Map<string, string[]>();
try {
// Get all folders from database
const folders = this.client.executeQuery<Folder>(
'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
));
}
/**

View File

@@ -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;

View File

@@ -300,6 +300,7 @@ type DeleteAccountInitiateResponse = {
serverEphemeral: string;
encryptionType: string;
encryptionSettings: string;
srpIdentity: string;
};
/**

View File

@@ -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;

View File

@@ -300,6 +300,7 @@ type DeleteAccountInitiateResponse = {
serverEphemeral: string;
encryptionType: string;
encryptionSettings: string;
srpIdentity: string;
};
/**

View File

@@ -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;