feat: search session globally across projects
This commit is contained in:
parent
44da078b3b
commit
51294da034
11 changed files with 863 additions and 50 deletions
|
|
@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
38
src/renderer/utils/keyboardUtils.ts
Normal file
38
src/renderer/utils/keyboardUtils.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
405
test/main/ipc/globalSearch.test.ts
Normal file
405
test/main/ipc/globalSearch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
161
test/renderer/utils/keyboardUtils.test.ts
Normal file
161
test/renderer/utils/keyboardUtils.test.ts
Normal 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('+');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue