diff --git a/src/main/http/search.ts b/src/main/http/search.ts index 920a6898..0a72d0a7 100644 --- a/src/main/http/search.ts +++ b/src/main/http/search.ts @@ -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 }; + } + }); } diff --git a/src/main/ipc/search.ts b/src/main/ipc/search.ts index 940897d8..b738c08e 100644 --- a/src/main/ipc/search.ts +++ b/src/main/ipc/search.ts @@ -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 { + 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 }; + } +} diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 90884236..65dfe785 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -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 { + 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. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f59e1c8..88882804 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 26f904ac..50672309 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -214,6 +214,12 @@ export class HttpAPIClient implements ElectronAPI { ); }; + searchAllProjects = (query: string, maxResults?: number): Promise => { + const params = new URLSearchParams({ q: query }); + if (maxResults) params.set('maxResults', String(maxResults)); + return this.get(`/api/search?${params}`); + }; + getSessionDetail = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 17f3c5f3..4777fb91 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -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): React.JSX.Element => { return ( @@ -459,15 +526,25 @@ export const CommandPalette = (): React.JSX.Element | null => { ) : (
- {sessionResults.map((result, index) => ( - 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 ( + handleSessionResultClick(result)} + highlightMatch={highlightMatch} + showProjectName={globalSearchEnabled} + projectName={projectName} + /> + ); + })}
)} @@ -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'}
@@ -490,6 +567,12 @@ export const CommandPalette = (): React.JSX.Element | null => { {' '} {searchMode === 'projects' ? 'select' : 'open'} + + + {formatModifierShortcut('G')} + {' '} + global + esc close diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index a6da3673..f0d4f3af 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -649,11 +649,6 @@ export const createTabSlice: StateCreator = (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 = (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 = (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 }); - } }, }); diff --git a/src/renderer/utils/keyboardUtils.ts b/src/renderer/utils/keyboardUtils.ts new file mode 100644 index 00000000..c525ad06 --- /dev/null +++ b/src/renderer/utils/keyboardUtils.ts @@ -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}`; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 4d405b02..9c8847ba 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -327,6 +327,7 @@ export interface ElectronAPI { query: string, maxResults?: number ) => Promise; + searchAllProjects: (query: string, maxResults?: number) => Promise; getSessionDetail: (projectId: string, sessionId: string) => Promise; getSessionMetrics: (projectId: string, sessionId: string) => Promise; getWaterfallData: (projectId: string, sessionId: string) => Promise; diff --git a/test/main/ipc/globalSearch.test.ts b/test/main/ipc/globalSearch.test.ts new file mode 100644 index 00000000..a25da8e9 --- /dev/null +++ b/test/main/ipc/globalSearch.test.ts @@ -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; + let mockSearchSessions: ReturnType; + + 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); + }); + }); +}); diff --git a/test/renderer/utils/keyboardUtils.test.ts b/test/renderer/utils/keyboardUtils.test.ts new file mode 100644 index 00000000..5303c261 --- /dev/null +++ b/test/renderer/utils/keyboardUtils.test.ts @@ -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('+'); + }); + }); + }); +});