agent-ecosystem/src/renderer/components/search/CommandPalette.tsx
iliya cb8017b0db feat: enhance file icon handling and keyboard shortcuts
- Introduced a new FileIcon component to render file-type icons with support for Devicon CDN and fallback to lucide-react icons.
- Added a glow effect for file icons in dark mode to improve visibility.
- Updated various components to utilize the new FileIcon component for consistent icon rendering.
- Refined keyboard shortcut handling to use event.code for layout-independent key detection, improving cross-platform compatibility.
- Enhanced the EditorTabBar with a context menu for tab management, including options to close tabs and manage open files more efficiently.
2026-03-01 14:24:24 +02:00

584 lines
20 KiB
TypeScript

/**
* CommandPalette - Spotlight/Alfred-like search modal.
* Triggered by Cmd+K.
*
* Behavior:
* - When NO project is selected: Searches projects by name/path
* - When a project IS selected: Searches conversations within that project
*/
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,
Globe,
Loader2,
MessageSquare,
Search,
User,
X,
} from 'lucide-react';
import type { RepositoryGroup, SearchResult } from '@renderer/types/data';
// =============================================================================
// Search Mode Type
// =============================================================================
type SearchMode = 'projects' | 'sessions';
// =============================================================================
// Project Search Result Item
// =============================================================================
interface ProjectResultItemProps {
repo: RepositoryGroup;
isSelected: boolean;
onClick: () => void;
}
const ProjectResultItemInner = ({
repo,
isSelected,
onClick,
}: Readonly<ProjectResultItemProps>): React.JSX.Element => {
const lastActivity = repo.mostRecentSession
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true })
: 'No recent activity';
return (
<button
onClick={onClick}
className={`w-full px-4 py-3 text-left transition-colors ${
isSelected ? 'bg-surface-raised' : 'hover:bg-surface-raised/50'
}`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0 text-text-secondary">
<FolderGit2 className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-text">{repo.name}</div>
<div className="mt-0.5 truncate font-mono text-xs text-text-muted">
{repo.worktrees[0]?.path || ''}
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-text-muted">
<span>{repo.totalSessions} sessions</span>
<span>·</span>
<span>{lastActivity}</span>
</div>
</div>
</div>
</button>
);
};
const ProjectResultItem = React.memo(ProjectResultItemInner);
// =============================================================================
// Session Search Result Item
// =============================================================================
interface SessionResultItemProps {
result: SearchResult;
isSelected: boolean;
onClick: () => void;
highlightMatch: (context: string, matchedText: string) => React.ReactNode;
showProjectName?: boolean;
projectName?: string;
}
const SessionResultItemInner = ({
result,
isSelected,
onClick,
highlightMatch,
showProjectName = false,
projectName,
}: Readonly<SessionResultItemProps>): React.JSX.Element => {
return (
<button
onClick={onClick}
className={`w-full px-4 py-3 text-left transition-colors ${
isSelected ? 'bg-surface-raised' : 'hover:bg-surface-raised/50'
}`}
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 shrink-0 ${
result.messageType === 'user' ? 'text-blue-400' : 'text-green-400'
}`}
>
{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">
{result.sessionTitle.slice(0, 60)}
{result.sessionTitle.length > 60 ? '...' : ''}
</span>
</div>
<div className="text-sm leading-relaxed text-text">
{highlightMatch(result.context, result.matchedText)}
</div>
<div className="text-text-muted/60 mt-1 text-xs">
{new Date(result.timestamp).toLocaleDateString()}{' '}
{new Date(result.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
</button>
);
};
const SessionResultItem = React.memo(SessionResultItemInner);
// =============================================================================
// Main Component
// =============================================================================
export const CommandPalette = (): React.JSX.Element | null => {
const {
commandPaletteOpen,
closeCommandPalette,
selectedProjectId,
navigateToSession,
repositoryGroups,
fetchRepositoryGroups,
selectRepository,
} = useStore(
useShallow((s) => ({
commandPaletteOpen: s.commandPaletteOpen,
closeCommandPalette: s.closeCommandPalette,
selectedProjectId: s.selectedProjectId,
navigateToSession: s.navigateToSession,
repositoryGroups: s.repositoryGroups,
fetchRepositoryGroups: s.fetchRepositoryGroups,
selectRepository: s.selectRepository,
}))
);
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [sessionResults, setSessionResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
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 OR global search is enabled
const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects';
// Filter projects for project search mode
const filteredProjects = useMemo(() => {
if (searchMode !== 'projects' || query.trim().length < 1) {
return repositoryGroups.slice(0, 10);
}
const q = query.toLowerCase().trim();
return repositoryGroups
.filter((repo) => {
if (repo.name.toLowerCase().includes(q)) return true;
const path = repo.worktrees[0]?.path || '';
if (path.toLowerCase().includes(q)) return true;
return false;
})
.slice(0, 10);
}, [repositoryGroups, query, searchMode]);
// Results count for current mode
const resultsCount = searchMode === 'projects' ? filteredProjects.length : sessionResults.length;
// Fetch repository groups if needed
useEffect(() => {
if (
commandPaletteOpen &&
(searchMode === 'projects' || globalSearchEnabled) &&
repositoryGroups.length === 0
) {
void fetchRepositoryGroups();
}
}, [
commandPaletteOpen,
searchMode,
globalSearchEnabled,
repositoryGroups.length,
fetchRepositoryGroups,
]);
// Focus input when palette opens
useEffect(() => {
if (commandPaletteOpen && inputRef.current) {
inputRef.current.focus();
setQuery('');
setSessionResults([]);
setSelectedIndex(0);
setTotalMatches(0);
setSearchIsPartial(false);
setGlobalSearchEnabled(false);
}
}, [commandPaletteOpen]);
// Search sessions with debounce (only in session mode)
useEffect(() => {
// 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 = globalSearchEnabled
? await api.searchAllProjects(query.trim(), 50)
: await api.searchSessions(selectedProjectId!, query.trim(), 50);
if (latestSearchRequestRef.current !== requestId) {
return;
}
setSessionResults(searchResult.results);
setTotalMatches(searchResult.totalMatches);
setSearchIsPartial(!!searchResult.isPartial);
setSelectedIndex(0);
} catch (error) {
if (latestSearchRequestRef.current !== requestId) {
return;
}
logger.error('Search error:', error);
setSessionResults([]);
setTotalMatches(0);
setSearchIsPartial(false);
} finally {
if (latestSearchRequestRef.current === requestId) {
setLoading(false);
}
}
}, 200);
return () => clearTimeout(timeoutId);
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);
// Reset selected index when results change
useEffect(() => {
setSelectedIndex(0);
}, [filteredProjects, sessionResults]);
// Handle project click
const handleProjectClick = useCallback(
(repo: RepositoryGroup) => {
closeCommandPalette();
selectRepository(repo.id);
},
[closeCommandPalette, selectRepository]
);
// Handle session result click
const handleSessionResultClick = useCallback(
(result: SearchResult) => {
closeCommandPalette();
navigateToSession(result.projectId, result.sessionId, true, {
query: query.trim(),
messageTimestamp: result.timestamp,
matchedText: result.matchedText,
targetGroupId: result.groupId,
targetMatchIndexInItem: result.matchIndexInItem,
targetMatchStartOffset: result.matchStartOffset,
targetMessageUuid: result.messageUuid,
});
},
[closeCommandPalette, navigateToSession, query]
);
// Handle backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
closeCommandPalette();
}
},
[closeCommandPalette]
);
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.code === 'KeyG' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setGlobalSearchEnabled((prev) => !prev);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeCommandPalette();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, resultsCount - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Enter' && resultsCount > 0) {
e.preventDefault();
if (searchMode === 'projects') {
const selected = filteredProjects[selectedIndex];
if (selected) {
handleProjectClick(selected);
}
} else {
const selected = sessionResults[selectedIndex];
if (selected) {
handleSessionResultClick(selected);
}
}
}
},
[
resultsCount,
selectedIndex,
closeCommandPalette,
searchMode,
filteredProjects,
sessionResults,
handleProjectClick,
handleSessionResultClick,
]
);
// Highlight matched text in context
const highlightMatch = useCallback((context: string, matchedText: string) => {
const lowerContext = context.toLowerCase();
const lowerMatch = matchedText.toLowerCase();
const matchIndex = lowerContext.indexOf(lowerMatch);
if (matchIndex === -1) {
return <span>{context}</span>;
}
const before = context.slice(0, matchIndex);
const match = context.slice(matchIndex, matchIndex + matchedText.length);
const after = context.slice(matchIndex + matchedText.length);
return (
<>
<span>{before}</span>
<mark
className="rounded px-0.5"
style={{
backgroundColor: 'var(--highlight-bg)',
color: 'var(--highlight-text)',
}}
>
{match}
</mark>
<span>{after}</span>
</>
);
}, []);
if (!commandPaletteOpen) {
return null;
}
return (
<div
role="presentation"
className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 pt-[15vh] backdrop-blur-sm"
onClick={handleBackdropClick}
>
<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 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>
{/* Search input */}
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search className="size-5 shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
searchMode === 'projects' ? 'Search projects...' : 'Search conversations...'
}
className="placeholder:text-text-muted/50 flex-1 bg-transparent text-base text-text focus:outline-none"
/>
{loading && <Loader2 className="size-4 animate-spin text-text-muted" />}
<button
onClick={closeCommandPalette}
className="rounded p-1 text-text-muted transition-colors hover:text-text"
>
<X className="size-4" />
</button>
</div>
{/* Results */}
<div className="max-h-[50vh] overflow-y-auto">
{searchMode === 'projects' ? (
// Project search results
filteredProjects.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
{query.trim() ? `No projects found for "${query}"` : 'No projects found'}
</div>
) : (
<div className="py-2">
{filteredProjects.map((repo, index) => (
<ProjectResultItem
key={repo.id}
repo={repo}
isSelected={index === selectedIndex}
onClick={() => handleProjectClick(repo)}
/>
))}
</div>
)
) : // Session search results
query.trim().length < 2 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
Type at least 2 characters to search
</div>
) : sessionResults.length === 0 && !loading ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
{searchIsPartial
? `No fast results in recent sessions for "${query}"`
: `No results found for "${query}"`}
</div>
) : (
<div className="py-2">
{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>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-4 py-2 text-xs text-text-muted">
<span>
{searchMode === 'projects'
? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}`
: totalMatches > 0
? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}`
: 'Type to search'}
</span>
<div className="flex items-center gap-4">
<span>
<kbd className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px]"></kbd>{' '}
navigate
</span>
<span>
<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>
</div>
</div>
</div>
</div>
);
};