feat: search session globally across projects

This commit is contained in:
KaustubhPatange 2026-02-21 12:46:01 +05:30
parent 44da078b3b
commit 51294da034
11 changed files with 863 additions and 50 deletions

View file

@ -47,4 +47,32 @@ export function registerSearchRoutes(app: FastifyInstance, services: HttpService
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
});
app.get<{
Querystring: { q?: string; maxResults?: string };
}>('/api/search', async (request) => {
const query = request.query.q ?? '';
try {
const validatedQuery = validateSearchQuery(query);
if (!validatedQuery.valid) {
logger.error(`GET global search rejected: ${validatedQuery.error ?? 'Invalid query'}`);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
const maxResults = coerceSearchMaxResults(
request.query.maxResults ? Number(request.query.maxResults) : undefined,
50
);
const result = await services.projectScanner.searchAllProjects(
validatedQuery.value!,
maxResults
);
return result;
} catch (error) {
logger.error('Error in GET global search:', error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
});
}

View file

@ -31,6 +31,7 @@ export function initializeSearchHandlers(contextRegistry: ServiceContextRegistry
*/
export function registerSearchHandlers(ipcMain: IpcMain): void {
ipcMain.handle('search-sessions', handleSearchSessions);
ipcMain.handle('search-all-projects', handleSearchAllProjects);
logger.info('Search handlers registered');
}
@ -40,6 +41,7 @@ export function registerSearchHandlers(ipcMain: IpcMain): void {
*/
export function removeSearchHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('search-sessions');
ipcMain.removeHandler('search-all-projects');
logger.info('Search handlers removed');
}
@ -81,3 +83,29 @@ async function handleSearchSessions(
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
}
/**
* Handler for 'search-all-projects' IPC call.
* Searches sessions across all projects for a query string.
*/
async function handleSearchAllProjects(
_event: IpcMainInvokeEvent,
query: string,
maxResults?: number
): Promise<SearchSessionsResult> {
try {
const validatedQuery = validateSearchQuery(query);
if (!validatedQuery.valid) {
logger.error(`search-all-projects rejected: ${validatedQuery.error ?? 'Invalid query'}`);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
const { projectScanner } = registry.getActive();
const safeMaxResults = coerceSearchMaxResults(maxResults, 50);
const result = await projectScanner.searchAllProjects(validatedQuery.value!, safeMaxResults);
return result;
} catch (error) {
logger.error('Error in search-all-projects:', error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
}

View file

@ -1070,6 +1070,74 @@ export class ProjectScanner {
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
}
/**
* Searches sessions across all projects for a query string.
* Filters out noise messages and returns matching content.
*
* @param query - Search query string
* @param maxResults - Maximum number of results to return (default 50)
*/
async searchAllProjects(query: string, maxResults: number = 50): Promise<SearchSessionsResult> {
const startedAt = Date.now();
try {
if (!query || query.trim().length === 0) {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
// Get all projects
const projects = await this.scan();
if (projects.length === 0) {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
// Search across all projects with bounded concurrency
const allResults: SearchSessionsResult[] = [];
const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 4;
for (let i = 0; i < projects.length; i += searchBatchSize) {
const batch = projects.slice(i, i + searchBatchSize);
const batchResults = await Promise.allSettled(
batch.map((project) => this.sessionSearcher.searchSessions(project.id, query, maxResults))
);
for (const result of batchResults) {
if (result.status === 'fulfilled') {
allResults.push(result.value);
}
}
// Check if we have enough results already
const totalMatches = allResults.reduce((sum, r) => sum + r.totalMatches, 0);
if (totalMatches >= maxResults) {
break;
}
}
// Merge results from all projects
const mergedResults = allResults.flatMap((r) => r.results);
const totalSessionsSearched = allResults.reduce((sum, r) => sum + r.sessionsSearched, 0);
// Sort by timestamp (most recent first) and limit to maxResults
mergedResults.sort((a, b) => b.timestamp - a.timestamp);
const limitedResults = mergedResults.slice(0, maxResults);
logger.debug(
`Global search completed: ${limitedResults.length} results from ${totalSessionsSearched} sessions across ${projects.length} projects in ${Date.now() - startedAt}ms`
);
return {
results: limitedResults,
totalMatches: limitedResults.length,
sessionsSearched: totalSessionsSearched,
query,
};
} catch (error) {
logger.error('Error searching all projects:', error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
}
/**
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
*/

View file

@ -135,6 +135,8 @@ const electronAPI: ElectronAPI = {
) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options),
searchSessions: (projectId: string, query: string, maxResults?: number) =>
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
searchAllProjects: (query: string, maxResults?: number) =>
ipcRenderer.invoke('search-all-projects', query, maxResults),
getSessionDetail: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-detail', projectId, sessionId),
getSessionMetrics: (projectId: string, sessionId: string) =>

View file

@ -214,6 +214,12 @@ export class HttpAPIClient implements ElectronAPI {
);
};
searchAllProjects = (query: string, maxResults?: number): Promise<SearchSessionsResult> => {
const params = new URLSearchParams({ q: query });
if (maxResults) params.set('maxResults', String(maxResults));
return this.get<SearchSessionsResult>(`/api/search?${params}`);
};
getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> =>
this.get<SessionDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`

View file

@ -11,12 +11,23 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { formatModifierShortcut } from '@renderer/utils/keyboardUtils';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:CommandPalette');
import { formatDistanceToNow } from 'date-fns';
import { Bot, FileText, FolderGit2, Loader2, MessageSquare, Search, User, X } from 'lucide-react';
import {
Bot,
FileText,
FolderGit2,
Globe,
Loader2,
MessageSquare,
Search,
User,
X,
} from 'lucide-react';
import type { RepositoryGroup, SearchResult } from '@renderer/types/data';
@ -83,6 +94,8 @@ interface SessionResultItemProps {
isSelected: boolean;
onClick: () => void;
highlightMatch: (context: string, matchedText: string) => React.ReactNode;
showProjectName?: boolean;
projectName?: string;
}
const SessionResultItemInner = ({
@ -90,6 +103,8 @@ const SessionResultItemInner = ({
isSelected,
onClick,
highlightMatch,
showProjectName = false,
projectName,
}: Readonly<SessionResultItemProps>): React.JSX.Element => {
return (
<button
@ -107,6 +122,12 @@ const SessionResultItemInner = ({
{result.messageType === 'user' ? <User className="size-4" /> : <Bot className="size-4" />}
</div>
<div className="min-w-0 flex-1">
{showProjectName && projectName && (
<div className="mb-1 flex items-center gap-2">
<FolderGit2 className="size-3 text-blue-400" />
<span className="truncate text-xs font-medium text-blue-400">{projectName}</span>
</div>
)}
<div className="mb-1 flex items-center gap-2">
<FileText className="size-3 text-text-muted" />
<span className="truncate text-xs text-text-muted">
@ -161,10 +182,11 @@ export const CommandPalette = (): React.JSX.Element | null => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [totalMatches, setTotalMatches] = useState(0);
const [searchIsPartial, setSearchIsPartial] = useState(false);
const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false);
const latestSearchRequestRef = useRef(0);
// Determine search mode based on whether a project is selected
const searchMode: SearchMode = selectedProjectId ? 'sessions' : 'projects';
// Determine search mode based on whether a project is selected OR global search is enabled
const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects';
// Filter projects for project search mode
const filteredProjects = useMemo(() => {
@ -188,10 +210,20 @@ export const CommandPalette = (): React.JSX.Element | null => {
// Fetch repository groups if needed
useEffect(() => {
if (commandPaletteOpen && searchMode === 'projects' && repositoryGroups.length === 0) {
if (
commandPaletteOpen &&
(searchMode === 'projects' || globalSearchEnabled) &&
repositoryGroups.length === 0
) {
void fetchRepositoryGroups();
}
}, [commandPaletteOpen, searchMode, repositoryGroups.length, fetchRepositoryGroups]);
}, [
commandPaletteOpen,
searchMode,
globalSearchEnabled,
repositoryGroups.length,
fetchRepositoryGroups,
]);
// Focus input when palette opens
useEffect(() => {
@ -202,29 +234,33 @@ export const CommandPalette = (): React.JSX.Element | null => {
setSelectedIndex(0);
setTotalMatches(0);
setSearchIsPartial(false);
setGlobalSearchEnabled(false);
}
}, [commandPaletteOpen]);
// Search sessions with debounce (only in session mode)
useEffect(() => {
if (
!commandPaletteOpen ||
searchMode !== 'sessions' ||
!selectedProjectId ||
query.trim().length < 2
) {
// Only clear results when query is too short or palette is closed
if (!commandPaletteOpen || query.trim().length < 2) {
setSessionResults([]);
setTotalMatches(0);
setSearchIsPartial(false);
return;
}
// Early return without clearing if we're not in the right mode
if (searchMode !== 'sessions' || (!globalSearchEnabled && !selectedProjectId)) {
return;
}
const timeoutId = setTimeout(async () => {
const requestId = latestSearchRequestRef.current + 1;
latestSearchRequestRef.current = requestId;
setLoading(true);
try {
const searchResult = await api.searchSessions(selectedProjectId, query.trim(), 50);
const searchResult = globalSearchEnabled
? await api.searchAllProjects(query.trim(), 50)
: await api.searchSessions(selectedProjectId!, query.trim(), 50);
if (latestSearchRequestRef.current !== requestId) {
return;
}
@ -248,7 +284,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
}, 200);
return () => clearTimeout(timeoutId);
}, [query, selectedProjectId, commandPaletteOpen, searchMode]);
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
// Reset selected index when results change
useEffect(() => {
@ -294,6 +330,12 @@ export const CommandPalette = (): React.JSX.Element | null => {
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setGlobalSearchEnabled((prev) => !prev);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeCommandPalette();
@ -383,23 +425,48 @@ export const CommandPalette = (): React.JSX.Element | null => {
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-surface shadow-2xl">
{/* Mode indicator */}
<div className="bg-surface-raised/50 border-b border-border px-4 py-2">
<div className="flex items-center gap-2">
{searchMode === 'projects' ? (
<>
<FolderGit2 className="size-3.5 text-text-muted" />
<span className="text-xs text-text-muted">Search projects</span>
</>
) : (
<>
<MessageSquare className="size-3.5 text-text-muted" />
<span className="text-xs text-text-muted">Search in projects</span>
<span className="text-text-muted/50 mx-1 text-xs">·</span>
<span className="truncate text-xs text-text-secondary">
{repositoryGroups.find((r) => r.worktrees.some((w) => w.id === selectedProjectId))
?.name ?? 'Current project'}
</span>
</>
)}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{searchMode === 'projects' ? (
<>
<FolderGit2 className="size-3.5 text-text-muted" />
<span className="text-xs text-text-muted">Search projects</span>
</>
) : (
<>
<MessageSquare className="size-3.5 text-text-muted" />
<span className="text-xs text-text-muted">
{globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
</span>
{!globalSearchEnabled && (
<>
<span className="text-text-muted/50 mx-1 text-xs">·</span>
<span className="truncate text-xs text-text-secondary">
{repositoryGroups.find((r) =>
r.worktrees.some((w) => w.id === selectedProjectId)
)?.name ?? 'Current project'}
</span>
</>
)}
</>
)}
</div>
<button
onClick={() => setGlobalSearchEnabled(!globalSearchEnabled)}
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors ${
globalSearchEnabled
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
: 'text-text-muted hover:bg-surface-raised hover:text-text'
}`}
title={
!globalSearchEnabled
? `Search across all projects (${formatModifierShortcut('G')})`
: undefined
}
>
<Globe className="size-3" />
<span>Global</span>
</button>
</div>
</div>
@ -459,15 +526,25 @@ export const CommandPalette = (): React.JSX.Element | null => {
</div>
) : (
<div className="py-2">
{sessionResults.map((result, index) => (
<SessionResultItem
key={`${result.sessionId}-${index}`}
result={result}
isSelected={index === selectedIndex}
onClick={() => handleSessionResultClick(result)}
highlightMatch={highlightMatch}
/>
))}
{sessionResults.map((result, index) => {
// Find project name for this result when in global search mode
const projectName = globalSearchEnabled
? repositoryGroups.find((r) => r.worktrees.some((w) => w.id === result.projectId))
?.name
: undefined;
return (
<SessionResultItem
key={`${result.sessionId}-${index}`}
result={result}
isSelected={index === selectedIndex}
onClick={() => handleSessionResultClick(result)}
highlightMatch={highlightMatch}
showProjectName={globalSearchEnabled}
projectName={projectName}
/>
);
})}
</div>
)}
</div>
@ -478,7 +555,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
{searchMode === 'projects'
? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}`
: totalMatches > 0
? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}`
? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}`
: 'Type to search'}
</span>
<div className="flex items-center gap-4">
@ -490,6 +567,12 @@ export const CommandPalette = (): React.JSX.Element | null => {
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]"></kbd>{' '}
{searchMode === 'projects' ? 'select' : 'open'}
</span>
<span>
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">
{formatModifierShortcut('G')}
</kbd>{' '}
global
</span>
<span>
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">esc</kbd> close
</span>

View file

@ -649,11 +649,6 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
) => {
const state = get();
// If different project, select it first
if (state.selectedProjectId !== projectId) {
state.selectProject(projectId);
}
// Check if session tab is already open in any pane
const allTabs = getAllTabs(state.paneLayout);
const existingTab =
@ -703,6 +698,9 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
const newState = get();
const newTabId = newState.activeTabId;
if (newTabId) {
// Re-focus tab via setActiveTab for proper sidebar sync
state.setActiveTab(newTabId);
const searchPayload = {
query: searchContext.query,
messageTimestamp: searchContext.messageTimestamp,
@ -731,10 +729,5 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
const newTabIdForFetch = get().activeTabId ?? undefined;
void state.fetchSessionDetail(projectId, sessionId, newTabIdForFetch);
}
// If opened from search, clear sidebar selection to deselect
if (fromSearch) {
set({ selectedSessionId: null });
}
},
});

View file

@ -0,0 +1,38 @@
/**
* Keyboard utility functions for platform-aware shortcuts
*/
/**
* Detect if running on macOS
*/
export function isMacOS(): boolean {
return navigator.userAgent.toLowerCase().includes('mac');
}
/**
* Get the primary modifier key name for the current platform
* @returns 'Cmd' on macOS, 'Ctrl' on other platforms
*/
export function getModifierKeyName(): string {
return isMacOS() ? 'Cmd' : 'Ctrl';
}
/**
* Get the primary modifier key symbol for the current platform
* @returns '⌘' on macOS, 'Ctrl' on other platforms
*/
export function getModifierKeySymbol(): string {
return isMacOS() ? '⌘' : 'Ctrl';
}
/**
* Format a keyboard shortcut for display
* @param key - The key to press (e.g., 'K', 'G', 'Enter')
* @param useSymbol - Whether to use symbols () or text (Cmd)
* @returns Formatted shortcut string (e.g., '⌘K' or 'Ctrl+K')
*/
export function formatModifierShortcut(key: string, useSymbol = true): string {
const modifier = useSymbol ? getModifierKeySymbol() : getModifierKeyName();
const separator = useSymbol && isMacOS() ? '' : '+';
return `${modifier}${separator}${key}`;
}

View file

@ -327,6 +327,7 @@ export interface ElectronAPI {
query: string,
maxResults?: number
) => Promise<SearchSessionsResult>;
searchAllProjects: (query: string, maxResults?: number) => Promise<SearchSessionsResult>;
getSessionDetail: (projectId: string, sessionId: string) => Promise<SessionDetail | null>;
getSessionMetrics: (projectId: string, sessionId: string) => Promise<SessionMetrics | null>;
getWaterfallData: (projectId: string, sessionId: string) => Promise<WaterfallData | null>;

View file

@ -0,0 +1,405 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectScanner } from '../../../src/main/services/discovery/ProjectScanner';
import type { Project, SearchSessionsResult } from '../../../src/main/types';
/**
* Tests for global search functionality across all projects
*/
describe('Global Search - ProjectScanner.searchAllProjects', () => {
let projectScanner: ProjectScanner;
let mockScan: ReturnType<typeof vi.fn>;
let mockSearchSessions: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Create a real ProjectScanner instance
projectScanner = new ProjectScanner();
// Mock the scan() method
mockScan = vi.fn();
projectScanner.scan = mockScan;
// Mock the sessionSearcher.searchSessions() method
mockSearchSessions = vi.fn();
// @ts-expect-error - Accessing private property for testing
projectScanner.sessionSearcher = {
searchSessions: mockSearchSessions,
};
});
describe('searchAllProjects', () => {
it('should return empty results for empty query', async () => {
const result = await projectScanner.searchAllProjects('', 50);
expect(result.results).toEqual([]);
expect(result.totalMatches).toBe(0);
expect(result.sessionsSearched).toBe(0);
expect(mockScan).not.toHaveBeenCalled();
});
it('should return empty results for whitespace query', async () => {
const result = await projectScanner.searchAllProjects(' ', 50);
expect(result.results).toEqual([]);
expect(result.totalMatches).toBe(0);
expect(result.sessionsSearched).toBe(0);
expect(mockScan).not.toHaveBeenCalled();
});
it('should return empty results when no projects exist', async () => {
mockScan.mockResolvedValue([]);
const result = await projectScanner.searchAllProjects('test', 50);
expect(result.results).toEqual([]);
expect(result.totalMatches).toBe(0);
expect(result.sessionsSearched).toBe(0);
expect(mockScan).toHaveBeenCalledOnce();
});
it('should search across multiple projects and merge results', async () => {
const now = Date.now();
// Mock scan() to return 2 projects
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now - 10000,
mostRecentSession: now,
},
{
id: 'project2',
path: '/path/to/project2',
name: 'Project 2',
sessions: ['session2'],
createdAt: now - 20000,
mostRecentSession: now - 1000,
},
];
mockScan.mockResolvedValue(mockProjects);
// Mock searchSessions() to return different results for each project
mockSearchSessions.mockImplementation((projectId: string) => {
if (projectId === 'project1') {
return Promise.resolve({
results: [
{
projectId: 'project1',
sessionId: 'session1',
sessionTitle: 'Test Session 1',
context: 'This is a test message',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now,
groupId: 'group1',
matchIndexInItem: 0,
matchStartOffset: 10,
messageUuid: 'uuid1',
},
],
totalMatches: 1,
sessionsSearched: 5,
query: 'test',
} satisfies SearchSessionsResult);
} else {
return Promise.resolve({
results: [
{
projectId: 'project2',
sessionId: 'session2',
sessionTitle: 'Test Session 2',
context: 'Another test message',
matchedText: 'test',
messageType: 'assistant' as const,
timestamp: now - 1000,
groupId: 'group2',
matchIndexInItem: 0,
matchStartOffset: 8,
messageUuid: 'uuid2',
},
],
totalMatches: 1,
sessionsSearched: 3,
query: 'test',
} satisfies SearchSessionsResult);
}
});
const result = await projectScanner.searchAllProjects('test', 50);
expect(mockScan).toHaveBeenCalledOnce();
expect(mockSearchSessions).toHaveBeenCalledTimes(2);
expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 50);
expect(mockSearchSessions).toHaveBeenCalledWith('project2', 'test', 50);
expect(result.results).toHaveLength(2);
expect(result.totalMatches).toBe(2);
expect(result.sessionsSearched).toBe(8); // 5 + 3
// Verify results from different projects
expect(result.results[0].projectId).toBe('project1');
expect(result.results[1].projectId).toBe('project2');
});
it('should sort results by timestamp (most recent first)', async () => {
const now = Date.now();
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now - 10000,
},
{
id: 'project2',
path: '/path/to/project2',
name: 'Project 2',
sessions: ['session2'],
createdAt: now - 20000,
},
];
mockScan.mockResolvedValue(mockProjects);
// Project1 has older result, Project2 has newer result
mockSearchSessions.mockImplementation((projectId: string) => {
if (projectId === 'project1') {
return Promise.resolve({
results: [
{
projectId: 'project1',
sessionId: 'session1',
sessionTitle: 'Old Session',
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now - 10000, // Older
groupId: 'group1',
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: 'uuid1',
},
],
totalMatches: 1,
sessionsSearched: 5,
query: 'test',
} satisfies SearchSessionsResult);
} else {
return Promise.resolve({
results: [
{
projectId: 'project2',
sessionId: 'session2',
sessionTitle: 'New Session',
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now, // Newer
groupId: 'group2',
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: 'uuid2',
},
],
totalMatches: 1,
sessionsSearched: 3,
query: 'test',
} satisfies SearchSessionsResult);
}
});
const result = await projectScanner.searchAllProjects('test', 50);
// Should be sorted newest first
expect(result.results[0].sessionTitle).toBe('New Session');
expect(result.results[1].sessionTitle).toBe('Old Session');
expect(result.results[0].timestamp).toBeGreaterThan(result.results[1].timestamp);
});
it('should respect maxResults limit', async () => {
const now = Date.now();
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now,
},
];
mockScan.mockResolvedValue(mockProjects);
// Return 30 results from search
const mockResults = Array.from({ length: 30 }, (_, i) => ({
projectId: 'project1',
sessionId: `session${i}`,
sessionTitle: `Session ${i}`,
context: 'test context',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now - i * 1000,
groupId: `group${i}`,
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: `uuid${i}`,
}));
mockSearchSessions.mockResolvedValue({
results: mockResults,
totalMatches: 30,
sessionsSearched: 50,
query: 'test',
} satisfies SearchSessionsResult);
const result = await projectScanner.searchAllProjects('test', 25);
expect(result.results.length).toBe(25); // Limited to maxResults
expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 25);
});
it('should handle search errors gracefully', async () => {
const now = Date.now();
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now,
},
{
id: 'project2',
path: '/path/to/project2',
name: 'Project 2',
sessions: ['session2'],
createdAt: now - 1000,
},
];
mockScan.mockResolvedValue(mockProjects);
// First project fails, second succeeds
mockSearchSessions.mockImplementation((projectId: string) => {
if (projectId === 'project1') {
return Promise.reject(new Error('Search failed'));
} else {
return Promise.resolve({
results: [
{
projectId: 'project2',
sessionId: 'session2',
sessionTitle: 'Test Session 2',
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now,
groupId: 'group2',
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: 'uuid2',
},
],
totalMatches: 1,
sessionsSearched: 3,
query: 'test',
} satisfies SearchSessionsResult);
}
});
const result = await projectScanner.searchAllProjects('test', 50);
// Should still return results from successful project
expect(result.results).toHaveLength(1);
expect(result.results[0].projectId).toBe('project2');
expect(result.totalMatches).toBe(1);
expect(result.sessionsSearched).toBe(3);
});
it('should use batched concurrency for local FS', async () => {
const now = Date.now();
// Create 10 projects to test batching (local uses batch size 4)
const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({
id: `project${i}`,
path: `/path/to/project${i}`,
name: `Project ${i}`,
sessions: [`session${i}`],
createdAt: now - i * 1000,
}));
mockScan.mockResolvedValue(mockProjects);
// Track call order to verify batching
const callOrder: string[] = [];
mockSearchSessions.mockImplementation((projectId: string) => {
callOrder.push(projectId);
return Promise.resolve({
results: [],
totalMatches: 0,
sessionsSearched: 1,
query: 'test',
} satisfies SearchSessionsResult);
});
await projectScanner.searchAllProjects('test', 50);
// All 10 projects should be searched
expect(mockSearchSessions).toHaveBeenCalledTimes(10);
expect(callOrder).toHaveLength(10);
});
it('should stop searching when enough results are found', async () => {
const now = Date.now();
// Create 10 projects
const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({
id: `project${i}`,
path: `/path/to/project${i}`,
name: `Project ${i}`,
sessions: [`session${i}`],
createdAt: now - i * 1000,
}));
mockScan.mockResolvedValue(mockProjects);
// Each project returns 10 results (total would be 100)
mockSearchSessions.mockImplementation((projectId: string) => {
const results = Array.from({ length: 10 }, (_, i) => ({
projectId,
sessionId: `session${i}`,
sessionTitle: `Session ${i}`,
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now - i * 1000,
groupId: `group${i}`,
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: `uuid${i}`,
}));
return Promise.resolve({
results,
totalMatches: 10,
sessionsSearched: 1,
query: 'test',
} satisfies SearchSessionsResult);
});
const result = await projectScanner.searchAllProjects('test', 50);
// Should stop after getting enough results (checks after each batch of 4)
// Batch 1 (4 projects): 40 matches < 50, continue
// Batch 2 (4 projects): 80 matches >= 50, stop
expect(mockSearchSessions.mock.calls.length).toBeGreaterThanOrEqual(4);
expect(mockSearchSessions.mock.calls.length).toBeLessThanOrEqual(8);
// Result should be limited to maxResults
expect(result.results.length).toBe(50);
});
});
});

View file

@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
formatModifierShortcut,
getModifierKeyName,
getModifierKeySymbol,
isMacOS,
} from '../../../src/renderer/utils/keyboardUtils';
describe('keyboardUtils', () => {
describe('isMacOS', () => {
beforeEach(() => {
// Reset userAgent before each test
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: '',
});
});
it('should return true when userAgent contains "mac"', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
expect(isMacOS()).toBe(true);
});
it('should return false when userAgent does not contain "mac"', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
});
expect(isMacOS()).toBe(false);
});
it('should be case-insensitive', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (MAC OS)',
});
expect(isMacOS()).toBe(true);
});
});
describe('getModifierKeyName', () => {
it('should return "Cmd" on macOS', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
expect(getModifierKeyName()).toBe('Cmd');
});
it('should return "Ctrl" on Windows', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
});
expect(getModifierKeyName()).toBe('Ctrl');
});
it('should return "Ctrl" on Linux', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (X11; Linux x86_64)',
});
expect(getModifierKeyName()).toBe('Ctrl');
});
});
describe('getModifierKeySymbol', () => {
it('should return "⌘" on macOS', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
expect(getModifierKeySymbol()).toBe('⌘');
});
it('should return "Ctrl" on Windows', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
});
expect(getModifierKeySymbol()).toBe('Ctrl');
});
it('should return "Ctrl" on Linux', () => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (X11; Linux x86_64)',
});
expect(getModifierKeySymbol()).toBe('Ctrl');
});
});
describe('formatModifierShortcut', () => {
describe('macOS', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
});
it('should format with symbol by default', () => {
expect(formatModifierShortcut('K')).toBe('⌘K');
});
it('should format with text when useSymbol is false', () => {
expect(formatModifierShortcut('K', false)).toBe('Cmd+K');
});
it('should work with different keys', () => {
expect(formatModifierShortcut('G')).toBe('⌘G');
expect(formatModifierShortcut('S')).toBe('⌘S');
expect(formatModifierShortcut('Enter')).toBe('⌘Enter');
});
});
describe('Windows/Linux', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'userAgent', {
writable: true,
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
});
});
it('should format with symbol by default', () => {
expect(formatModifierShortcut('K')).toBe('Ctrl+K');
});
it('should format with text when useSymbol is false', () => {
expect(formatModifierShortcut('K', false)).toBe('Ctrl+K');
});
it('should work with different keys', () => {
expect(formatModifierShortcut('G')).toBe('Ctrl+G');
expect(formatModifierShortcut('S')).toBe('Ctrl+S');
expect(formatModifierShortcut('Enter')).toBe('Ctrl+Enter');
});
it('should always include + separator', () => {
expect(formatModifierShortcut('K')).toContain('+');
expect(formatModifierShortcut('K', false)).toContain('+');
});
});
});
});