agent-ecosystem/src/renderer/components/team/editor/QuickOpenDialog.tsx

164 lines
5.2 KiB
TypeScript

/**
* Quick Open dialog (Cmd+P) — fuzzy file search using cmdk.
*
* Escape closes dialog (not the editor overlay).
* Loads ALL project files via backend API on mount (not limited to expanded dirs).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { Command } from 'cmdk';
import { Loader2 } from 'lucide-react';
import { getFileIcon } from './fileIcons';
import type { QuickOpenFile } from '@shared/types/editor';
// =============================================================================
// Types
// =============================================================================
interface QuickOpenDialogProps {
onClose: () => void;
onSelectFile: (filePath: string) => void;
}
// =============================================================================
// Component
// =============================================================================
export const QuickOpenDialog = ({
onClose,
onSelectFile,
}: QuickOpenDialogProps): React.ReactElement => {
const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const dialogRef = useRef<HTMLDivElement>(null);
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
const [loading, setLoading] = useState(true);
// Load all project files on mount via backend API
useEffect(() => {
let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setLoading(true);
window.electronAPI.editor
.listFiles()
.then((files) => {
if (!cancelled) {
setAllFiles(files);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectPath]);
// Escape to close dialog (not overlay)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
},
[onClose]
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [handleKeyDown]);
const handleSelect = useCallback(
(value: string) => {
// value is relativePath from cmdk — look up full path
const file = allFiles.find((f) => f.relativePath === value);
if (file) {
onSelectFile(file.path);
onClose();
}
},
[allFiles, onSelectFile, onClose]
);
// Memoize file icon lookups
const fileItems = useMemo(
() =>
allFiles.map((file) => ({
...file,
iconInfo: getFileIcon(file.name),
})),
[allFiles]
);
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
role="presentation"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t('editor.quickOpen.title')}
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
>
<Command label={t('editor.quickOpen.title')} shouldFilter={true}>
<Command.Input
placeholder={t('editor.quickOpen.searchPlaceholder')}
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-text outline-none placeholder:text-text-muted"
autoFocus
/>
<Command.List className="max-h-80 overflow-y-auto p-1">
{loading && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-text-muted">
<Loader2 className="size-4 animate-spin" />
<span>{t('editor.quickOpen.loading')}</span>
</div>
)}
{!loading && (
<Command.Empty className="p-6 text-center text-sm text-text-muted">
{t('editor.quickOpen.empty')}
</Command.Empty>
)}
{fileItems.map((file) => {
const Icon = file.iconInfo.icon;
return (
<Command.Item
key={file.path}
value={file.relativePath}
onSelect={() => handleSelect(file.relativePath)}
className="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-sm text-text-secondary aria-selected:bg-surface-raised aria-selected:text-text"
>
<Icon className="size-4 shrink-0" style={{ color: file.iconInfo.color }} />
<span className="truncate font-medium">{file.name}</span>
<span className="ml-auto truncate text-xs text-text-muted">
{file.relativePath}
</span>
</Command.Item>
);
})}
</Command.List>
</Command>
</div>
</div>
);
};