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 };
|
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 {
|
export function registerSearchHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle('search-sessions', handleSearchSessions);
|
ipcMain.handle('search-sessions', handleSearchSessions);
|
||||||
|
ipcMain.handle('search-all-projects', handleSearchAllProjects);
|
||||||
|
|
||||||
logger.info('Search handlers registered');
|
logger.info('Search handlers registered');
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +41,7 @@ export function registerSearchHandlers(ipcMain: IpcMain): void {
|
||||||
*/
|
*/
|
||||||
export function removeSearchHandlers(ipcMain: IpcMain): void {
|
export function removeSearchHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler('search-sessions');
|
ipcMain.removeHandler('search-sessions');
|
||||||
|
ipcMain.removeHandler('search-all-projects');
|
||||||
|
|
||||||
logger.info('Search handlers removed');
|
logger.info('Search handlers removed');
|
||||||
}
|
}
|
||||||
|
|
@ -81,3 +83,29 @@ async function handleSearchSessions(
|
||||||
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
|
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);
|
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.
|
* 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),
|
) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options),
|
||||||
searchSessions: (projectId: string, query: string, maxResults?: number) =>
|
searchSessions: (projectId: string, query: string, maxResults?: number) =>
|
||||||
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
|
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
|
||||||
|
searchAllProjects: (query: string, maxResults?: number) =>
|
||||||
|
ipcRenderer.invoke('search-all-projects', query, maxResults),
|
||||||
getSessionDetail: (projectId: string, sessionId: string) =>
|
getSessionDetail: (projectId: string, sessionId: string) =>
|
||||||
ipcRenderer.invoke('get-session-detail', projectId, sessionId),
|
ipcRenderer.invoke('get-session-detail', projectId, sessionId),
|
||||||
getSessionMetrics: (projectId: string, sessionId: string) =>
|
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> =>
|
getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> =>
|
||||||
this.get<SessionDetail | null>(
|
this.get<SessionDetail | null>(
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
|
`/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 { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { formatModifierShortcut } from '@renderer/utils/keyboardUtils';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
const logger = createLogger('Component:CommandPalette');
|
const logger = createLogger('Component:CommandPalette');
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
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';
|
import type { RepositoryGroup, SearchResult } from '@renderer/types/data';
|
||||||
|
|
||||||
|
|
@ -83,6 +94,8 @@ interface SessionResultItemProps {
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
highlightMatch: (context: string, matchedText: string) => React.ReactNode;
|
highlightMatch: (context: string, matchedText: string) => React.ReactNode;
|
||||||
|
showProjectName?: boolean;
|
||||||
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionResultItemInner = ({
|
const SessionResultItemInner = ({
|
||||||
|
|
@ -90,6 +103,8 @@ const SessionResultItemInner = ({
|
||||||
isSelected,
|
isSelected,
|
||||||
onClick,
|
onClick,
|
||||||
highlightMatch,
|
highlightMatch,
|
||||||
|
showProjectName = false,
|
||||||
|
projectName,
|
||||||
}: Readonly<SessionResultItemProps>): React.JSX.Element => {
|
}: Readonly<SessionResultItemProps>): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -107,6 +122,12 @@ const SessionResultItemInner = ({
|
||||||
{result.messageType === 'user' ? <User className="size-4" /> : <Bot className="size-4" />}
|
{result.messageType === 'user' ? <User className="size-4" /> : <Bot className="size-4" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<FileText className="size-3 text-text-muted" />
|
<FileText className="size-3 text-text-muted" />
|
||||||
<span className="truncate text-xs 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 [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [totalMatches, setTotalMatches] = useState(0);
|
const [totalMatches, setTotalMatches] = useState(0);
|
||||||
const [searchIsPartial, setSearchIsPartial] = useState(false);
|
const [searchIsPartial, setSearchIsPartial] = useState(false);
|
||||||
|
const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false);
|
||||||
const latestSearchRequestRef = useRef(0);
|
const latestSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
// Determine search mode based on whether a project is selected
|
// Determine search mode based on whether a project is selected OR global search is enabled
|
||||||
const searchMode: SearchMode = selectedProjectId ? 'sessions' : 'projects';
|
const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects';
|
||||||
|
|
||||||
// Filter projects for project search mode
|
// Filter projects for project search mode
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
|
|
@ -188,10 +210,20 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
// Fetch repository groups if needed
|
// Fetch repository groups if needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (commandPaletteOpen && searchMode === 'projects' && repositoryGroups.length === 0) {
|
if (
|
||||||
|
commandPaletteOpen &&
|
||||||
|
(searchMode === 'projects' || globalSearchEnabled) &&
|
||||||
|
repositoryGroups.length === 0
|
||||||
|
) {
|
||||||
void fetchRepositoryGroups();
|
void fetchRepositoryGroups();
|
||||||
}
|
}
|
||||||
}, [commandPaletteOpen, searchMode, repositoryGroups.length, fetchRepositoryGroups]);
|
}, [
|
||||||
|
commandPaletteOpen,
|
||||||
|
searchMode,
|
||||||
|
globalSearchEnabled,
|
||||||
|
repositoryGroups.length,
|
||||||
|
fetchRepositoryGroups,
|
||||||
|
]);
|
||||||
|
|
||||||
// Focus input when palette opens
|
// Focus input when palette opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -202,29 +234,33 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
setTotalMatches(0);
|
setTotalMatches(0);
|
||||||
setSearchIsPartial(false);
|
setSearchIsPartial(false);
|
||||||
|
setGlobalSearchEnabled(false);
|
||||||
}
|
}
|
||||||
}, [commandPaletteOpen]);
|
}, [commandPaletteOpen]);
|
||||||
|
|
||||||
// Search sessions with debounce (only in session mode)
|
// Search sessions with debounce (only in session mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
// Only clear results when query is too short or palette is closed
|
||||||
!commandPaletteOpen ||
|
if (!commandPaletteOpen || query.trim().length < 2) {
|
||||||
searchMode !== 'sessions' ||
|
|
||||||
!selectedProjectId ||
|
|
||||||
query.trim().length < 2
|
|
||||||
) {
|
|
||||||
setSessionResults([]);
|
setSessionResults([]);
|
||||||
setTotalMatches(0);
|
setTotalMatches(0);
|
||||||
setSearchIsPartial(false);
|
setSearchIsPartial(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early return without clearing if we're not in the right mode
|
||||||
|
if (searchMode !== 'sessions' || (!globalSearchEnabled && !selectedProjectId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(async () => {
|
const timeoutId = setTimeout(async () => {
|
||||||
const requestId = latestSearchRequestRef.current + 1;
|
const requestId = latestSearchRequestRef.current + 1;
|
||||||
latestSearchRequestRef.current = requestId;
|
latestSearchRequestRef.current = requestId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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) {
|
if (latestSearchRequestRef.current !== requestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -248,7 +284,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [query, selectedProjectId, commandPaletteOpen, searchMode]);
|
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
|
||||||
|
|
||||||
// Reset selected index when results change
|
// Reset selected index when results change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -294,6 +330,12 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setGlobalSearchEnabled((prev) => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeCommandPalette();
|
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">
|
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-surface shadow-2xl">
|
||||||
{/* Mode indicator */}
|
{/* Mode indicator */}
|
||||||
<div className="bg-surface-raised/50 border-b border-border px-4 py-2">
|
<div className="bg-surface-raised/50 border-b border-border px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{searchMode === 'projects' ? (
|
<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>
|
<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>
|
<MessageSquare className="size-3.5 text-text-muted" />
|
||||||
<span className="text-text-muted/50 mx-1 text-xs">·</span>
|
<span className="text-xs text-text-muted">
|
||||||
<span className="truncate text-xs text-text-secondary">
|
{globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
|
||||||
{repositoryGroups.find((r) => r.worktrees.some((w) => w.id === selectedProjectId))
|
</span>
|
||||||
?.name ?? 'Current project'}
|
{!globalSearchEnabled && (
|
||||||
</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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -459,15 +526,25 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{sessionResults.map((result, index) => (
|
{sessionResults.map((result, index) => {
|
||||||
<SessionResultItem
|
// Find project name for this result when in global search mode
|
||||||
key={`${result.sessionId}-${index}`}
|
const projectName = globalSearchEnabled
|
||||||
result={result}
|
? repositoryGroups.find((r) => r.worktrees.some((w) => w.id === result.projectId))
|
||||||
isSelected={index === selectedIndex}
|
?.name
|
||||||
onClick={() => handleSessionResultClick(result)}
|
: undefined;
|
||||||
highlightMatch={highlightMatch}
|
|
||||||
/>
|
return (
|
||||||
))}
|
<SessionResultItem
|
||||||
|
key={`${result.sessionId}-${index}`}
|
||||||
|
result={result}
|
||||||
|
isSelected={index === selectedIndex}
|
||||||
|
onClick={() => handleSessionResultClick(result)}
|
||||||
|
highlightMatch={highlightMatch}
|
||||||
|
showProjectName={globalSearchEnabled}
|
||||||
|
projectName={projectName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -478,7 +555,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
{searchMode === 'projects'
|
{searchMode === 'projects'
|
||||||
? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}`
|
? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}`
|
||||||
: totalMatches > 0
|
: totalMatches > 0
|
||||||
? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}`
|
? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}`
|
||||||
: 'Type to search'}
|
: 'Type to search'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<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>{' '}
|
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">↵</kbd>{' '}
|
||||||
{searchMode === 'projects' ? 'select' : 'open'}
|
{searchMode === 'projects' ? 'select' : 'open'}
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">
|
||||||
|
{formatModifierShortcut('G')}
|
||||||
|
</kbd>{' '}
|
||||||
|
global
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">esc</kbd> close
|
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]">esc</kbd> close
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -649,11 +649,6 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
) => {
|
) => {
|
||||||
const state = get();
|
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
|
// Check if session tab is already open in any pane
|
||||||
const allTabs = getAllTabs(state.paneLayout);
|
const allTabs = getAllTabs(state.paneLayout);
|
||||||
const existingTab =
|
const existingTab =
|
||||||
|
|
@ -703,6 +698,9 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
const newState = get();
|
const newState = get();
|
||||||
const newTabId = newState.activeTabId;
|
const newTabId = newState.activeTabId;
|
||||||
if (newTabId) {
|
if (newTabId) {
|
||||||
|
// Re-focus tab via setActiveTab for proper sidebar sync
|
||||||
|
state.setActiveTab(newTabId);
|
||||||
|
|
||||||
const searchPayload = {
|
const searchPayload = {
|
||||||
query: searchContext.query,
|
query: searchContext.query,
|
||||||
messageTimestamp: searchContext.messageTimestamp,
|
messageTimestamp: searchContext.messageTimestamp,
|
||||||
|
|
@ -731,10 +729,5 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
const newTabIdForFetch = get().activeTabId ?? undefined;
|
const newTabIdForFetch = get().activeTabId ?? undefined;
|
||||||
void state.fetchSessionDetail(projectId, sessionId, newTabIdForFetch);
|
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,
|
query: string,
|
||||||
maxResults?: number
|
maxResults?: number
|
||||||
) => Promise<SearchSessionsResult>;
|
) => Promise<SearchSessionsResult>;
|
||||||
|
searchAllProjects: (query: string, maxResults?: number) => Promise<SearchSessionsResult>;
|
||||||
getSessionDetail: (projectId: string, sessionId: string) => Promise<SessionDetail | null>;
|
getSessionDetail: (projectId: string, sessionId: string) => Promise<SessionDetail | null>;
|
||||||
getSessionMetrics: (projectId: string, sessionId: string) => Promise<SessionMetrics | null>;
|
getSessionMetrics: (projectId: string, sessionId: string) => Promise<SessionMetrics | null>;
|
||||||
getWaterfallData: (projectId: string, sessionId: string) => Promise<WaterfallData | 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