mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-04-03 06:24:01 -04:00
Update search result display when searching inside subfolders (#1695)
This commit is contained in:
@@ -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)} >{' '}
|
||||
</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} >{' '}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{getItemName(item)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -300,6 +300,7 @@ type DeleteAccountInitiateResponse = {
|
||||
serverEphemeral: string;
|
||||
encryptionType: string;
|
||||
encryptionSettings: string;
|
||||
srpIdentity: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -300,6 +300,7 @@ type DeleteAccountInitiateResponse = {
|
||||
serverEphemeral: string;
|
||||
encryptionType: string;
|
||||
encryptionSettings: string;
|
||||
srpIdentity: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user