@evolve.labs/devflow 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude/commands/agents/architect.md +1162 -0
  2. package/.claude/commands/agents/architect.meta.yaml +124 -0
  3. package/.claude/commands/agents/builder.md +1432 -0
  4. package/.claude/commands/agents/builder.meta.yaml +117 -0
  5. package/.claude/commands/agents/chronicler.md +633 -0
  6. package/.claude/commands/agents/chronicler.meta.yaml +217 -0
  7. package/.claude/commands/agents/guardian.md +456 -0
  8. package/.claude/commands/agents/guardian.meta.yaml +127 -0
  9. package/.claude/commands/agents/strategist.md +483 -0
  10. package/.claude/commands/agents/strategist.meta.yaml +158 -0
  11. package/.claude/commands/agents/system-designer.md +1137 -0
  12. package/.claude/commands/agents/system-designer.meta.yaml +156 -0
  13. package/.claude/commands/devflow-help.md +93 -0
  14. package/.claude/commands/devflow-status.md +60 -0
  15. package/.claude/commands/quick/create-adr.md +82 -0
  16. package/.claude/commands/quick/new-feature.md +57 -0
  17. package/.claude/commands/quick/security-check.md +54 -0
  18. package/.claude/commands/quick/system-design.md +58 -0
  19. package/.claude_project +52 -0
  20. package/.devflow/agents/architect.meta.yaml +122 -0
  21. package/.devflow/agents/builder.meta.yaml +116 -0
  22. package/.devflow/agents/chronicler.meta.yaml +222 -0
  23. package/.devflow/agents/guardian.meta.yaml +127 -0
  24. package/.devflow/agents/strategist.meta.yaml +158 -0
  25. package/.devflow/agents/system-designer.meta.yaml +265 -0
  26. package/.devflow/project.yaml +242 -0
  27. package/.gitignore-template +84 -0
  28. package/LICENSE +21 -0
  29. package/README.md +249 -0
  30. package/bin/devflow.js +54 -0
  31. package/lib/autopilot.js +235 -0
  32. package/lib/autopilotConstants.js +213 -0
  33. package/lib/constants.js +95 -0
  34. package/lib/init.js +200 -0
  35. package/lib/update.js +181 -0
  36. package/lib/utils.js +157 -0
  37. package/lib/web.js +119 -0
  38. package/package.json +57 -0
  39. package/web/CHANGELOG.md +192 -0
  40. package/web/README.md +156 -0
  41. package/web/app/api/autopilot/execute/route.ts +102 -0
  42. package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
  43. package/web/app/api/files/route.ts +280 -0
  44. package/web/app/api/files/tree/route.ts +160 -0
  45. package/web/app/api/git/route.ts +201 -0
  46. package/web/app/api/health/route.ts +94 -0
  47. package/web/app/api/project/open/route.ts +134 -0
  48. package/web/app/api/search/route.ts +247 -0
  49. package/web/app/api/specs/route.ts +405 -0
  50. package/web/app/api/terminal/route.ts +222 -0
  51. package/web/app/globals.css +160 -0
  52. package/web/app/ide/layout.tsx +43 -0
  53. package/web/app/ide/page.tsx +216 -0
  54. package/web/app/layout.tsx +34 -0
  55. package/web/app/page.tsx +303 -0
  56. package/web/components/agents/AgentIcons.tsx +281 -0
  57. package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
  58. package/web/components/autopilot/AutopilotPanel.tsx +299 -0
  59. package/web/components/dashboard/DashboardPanel.tsx +393 -0
  60. package/web/components/editor/Breadcrumbs.tsx +134 -0
  61. package/web/components/editor/EditorPanel.tsx +120 -0
  62. package/web/components/editor/EditorTabs.tsx +229 -0
  63. package/web/components/editor/MarkdownPreview.tsx +154 -0
  64. package/web/components/editor/MermaidDiagram.tsx +113 -0
  65. package/web/components/editor/MonacoEditor.tsx +177 -0
  66. package/web/components/editor/TabContextMenu.tsx +207 -0
  67. package/web/components/git/GitPanel.tsx +534 -0
  68. package/web/components/layout/Shell.tsx +15 -0
  69. package/web/components/layout/StatusBar.tsx +100 -0
  70. package/web/components/modals/CommandPalette.tsx +393 -0
  71. package/web/components/modals/GlobalSearch.tsx +348 -0
  72. package/web/components/modals/QuickOpen.tsx +241 -0
  73. package/web/components/modals/RecentFiles.tsx +208 -0
  74. package/web/components/projects/ProjectSelector.tsx +147 -0
  75. package/web/components/settings/SettingItem.tsx +150 -0
  76. package/web/components/settings/SettingsPanel.tsx +323 -0
  77. package/web/components/specs/SpecsPanel.tsx +1091 -0
  78. package/web/components/terminal/TerminalPanel.tsx +683 -0
  79. package/web/components/ui/ContextMenu.tsx +182 -0
  80. package/web/components/ui/LoadingSpinner.tsx +66 -0
  81. package/web/components/ui/ResizeHandle.tsx +110 -0
  82. package/web/components/ui/Skeleton.tsx +108 -0
  83. package/web/components/ui/SkipLinks.tsx +37 -0
  84. package/web/components/ui/Toaster.tsx +57 -0
  85. package/web/hooks/useFocusTrap.ts +141 -0
  86. package/web/hooks/useKeyboardShortcuts.ts +169 -0
  87. package/web/hooks/useListNavigation.ts +237 -0
  88. package/web/lib/autopilotConstants.ts +213 -0
  89. package/web/lib/constants/agents.ts +67 -0
  90. package/web/lib/git.ts +339 -0
  91. package/web/lib/ptyManager.ts +191 -0
  92. package/web/lib/specsParser.ts +299 -0
  93. package/web/lib/stores/autopilotStore.ts +288 -0
  94. package/web/lib/stores/fileStore.ts +550 -0
  95. package/web/lib/stores/gitStore.ts +386 -0
  96. package/web/lib/stores/projectStore.ts +196 -0
  97. package/web/lib/stores/settingsStore.ts +126 -0
  98. package/web/lib/stores/specsStore.ts +297 -0
  99. package/web/lib/stores/uiStore.ts +175 -0
  100. package/web/lib/types/index.ts +177 -0
  101. package/web/lib/utils.ts +98 -0
  102. package/web/next.config.js +50 -0
  103. package/web/package.json +54 -0
  104. package/web/postcss.config.js +6 -0
  105. package/web/tailwind.config.ts +68 -0
  106. package/web/tsconfig.json +41 -0
@@ -0,0 +1,348 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { useUIStore } from '@/lib/stores/uiStore';
5
+ import { useProjectStore } from '@/lib/stores/projectStore';
6
+ import { useFileStore } from '@/lib/stores/fileStore';
7
+ import {
8
+ Search,
9
+ File,
10
+ FileText,
11
+ FileCode,
12
+ FileJson,
13
+ X,
14
+ ChevronDown,
15
+ ChevronRight,
16
+ CaseSensitive,
17
+ Regex,
18
+ } from 'lucide-react';
19
+ import { cn } from '@/lib/utils';
20
+ import { useFocusTrap } from '@/hooks/useFocusTrap';
21
+
22
+ interface SearchMatch {
23
+ line: number;
24
+ content: string;
25
+ matchStart: number;
26
+ matchEnd: number;
27
+ }
28
+
29
+ interface SearchResult {
30
+ filePath: string;
31
+ relativePath: string;
32
+ fileName: string;
33
+ matches: SearchMatch[];
34
+ }
35
+
36
+ const getFileIcon = (fileName: string) => {
37
+ const ext = fileName.split('.').pop()?.toLowerCase();
38
+ switch (ext) {
39
+ case 'md':
40
+ return <FileText className="w-4 h-4 text-blue-400" />;
41
+ case 'json':
42
+ case 'yaml':
43
+ case 'yml':
44
+ return <FileJson className="w-4 h-4 text-yellow-400" />;
45
+ case 'ts':
46
+ case 'tsx':
47
+ case 'js':
48
+ case 'jsx':
49
+ return <FileCode className="w-4 h-4 text-green-400" />;
50
+ default:
51
+ return <File className="w-4 h-4 text-gray-400" />;
52
+ }
53
+ };
54
+
55
+ function HighlightedMatch({ match, query }: { match: SearchMatch; query: string }) {
56
+ const { content, matchStart, matchEnd } = match;
57
+
58
+ // Create highlighted segments
59
+ const before = content.slice(0, matchStart);
60
+ const highlighted = content.slice(matchStart, matchEnd);
61
+ const after = content.slice(matchEnd);
62
+
63
+ return (
64
+ <div className="text-xs font-mono text-gray-400 truncate">
65
+ <span className="text-gray-600">{match.line}: </span>
66
+ <span>{before}</span>
67
+ <span className="bg-yellow-500/30 text-yellow-200">{highlighted}</span>
68
+ <span>{after}</span>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ export function GlobalSearch() {
74
+ const [query, setQuery] = useState('');
75
+ const [results, setResults] = useState<SearchResult[]>([]);
76
+ const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
77
+ const [selectedIndex, setSelectedIndex] = useState(0);
78
+ const [isLoading, setIsLoading] = useState(false);
79
+ const [totalMatches, setTotalMatches] = useState(0);
80
+ const [caseSensitive, setCaseSensitive] = useState(false);
81
+ const [useRegex, setUseRegex] = useState(false);
82
+ const inputRef = useRef<HTMLInputElement>(null);
83
+ const listRef = useRef<HTMLDivElement>(null);
84
+ const modalRef = useRef<HTMLDivElement>(null);
85
+
86
+ const { activeModal, closeModal } = useUIStore();
87
+ const { currentProject } = useProjectStore();
88
+ const { openFile, setScrollToLine } = useFileStore();
89
+
90
+ const isOpen = activeModal === 'globalSearch';
91
+
92
+ // Focus trap for accessibility
93
+ useFocusTrap(modalRef, isOpen, {
94
+ onEscape: closeModal,
95
+ autoFocus: false, // We handle focus manually to the input
96
+ });
97
+
98
+ // Focus input when opened
99
+ useEffect(() => {
100
+ if (isOpen) {
101
+ setQuery('');
102
+ setResults([]);
103
+ setExpandedFiles(new Set());
104
+ setSelectedIndex(0);
105
+ setTotalMatches(0);
106
+ setTimeout(() => inputRef.current?.focus(), 50);
107
+ }
108
+ }, [isOpen]);
109
+
110
+ // Search content
111
+ const searchContent = useCallback(async (searchQuery: string) => {
112
+ if (!currentProject || !searchQuery.trim()) {
113
+ setResults([]);
114
+ setTotalMatches(0);
115
+ return;
116
+ }
117
+
118
+ setIsLoading(true);
119
+ try {
120
+ const params = new URLSearchParams({
121
+ projectPath: currentProject.path,
122
+ query: searchQuery,
123
+ type: 'content',
124
+ caseSensitive: caseSensitive.toString(),
125
+ regex: useRegex.toString(),
126
+ limit: '100',
127
+ });
128
+
129
+ const response = await fetch(`/api/search?${params}`);
130
+ const data = await response.json();
131
+
132
+ if (data.results) {
133
+ setResults(data.results);
134
+ setTotalMatches(data.totalMatches || 0);
135
+ // Auto-expand first few results
136
+ const firstFiles = data.results.slice(0, 3).map((r: SearchResult) => r.filePath);
137
+ setExpandedFiles(new Set(firstFiles));
138
+ setSelectedIndex(0);
139
+ }
140
+ } catch (error) {
141
+ console.error('Search error:', error);
142
+ } finally {
143
+ setIsLoading(false);
144
+ }
145
+ }, [currentProject, caseSensitive, useRegex]);
146
+
147
+ // Debounced search
148
+ useEffect(() => {
149
+ if (!isOpen) return;
150
+
151
+ const timer = setTimeout(() => {
152
+ searchContent(query);
153
+ }, 300);
154
+
155
+ return () => clearTimeout(timer);
156
+ }, [query, isOpen, searchContent]);
157
+
158
+ // Keyboard navigation
159
+ const handleKeyDown = (e: React.KeyboardEvent) => {
160
+ switch (e.key) {
161
+ case 'Escape':
162
+ e.preventDefault();
163
+ closeModal();
164
+ break;
165
+ case 'Enter':
166
+ e.preventDefault();
167
+ if (results.length > 0) {
168
+ const file = results[0];
169
+ if (file && file.matches[0]) {
170
+ handleMatchClick(file.filePath, file.matches[0].line);
171
+ }
172
+ }
173
+ break;
174
+ }
175
+ };
176
+
177
+ const toggleFile = (filePath: string) => {
178
+ setExpandedFiles((prev) => {
179
+ const next = new Set(prev);
180
+ if (next.has(filePath)) {
181
+ next.delete(filePath);
182
+ } else {
183
+ next.add(filePath);
184
+ }
185
+ return next;
186
+ });
187
+ };
188
+
189
+ const handleMatchClick = (filePath: string, line: number) => {
190
+ openFile(filePath);
191
+ setScrollToLine(line);
192
+ closeModal();
193
+ };
194
+
195
+ if (!isOpen) return null;
196
+
197
+ return (
198
+ <div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]">
199
+ {/* Backdrop */}
200
+ <div
201
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
202
+ onClick={closeModal}
203
+ aria-hidden="true"
204
+ />
205
+
206
+ {/* Modal */}
207
+ <div
208
+ ref={modalRef}
209
+ className="relative w-full max-w-3xl bg-[#12121a] border border-white/10 rounded-xl shadow-2xl overflow-hidden"
210
+ role="dialog"
211
+ aria-modal="true"
212
+ aria-label="Search in files"
213
+ >
214
+ {/* Search Input */}
215
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-white/10">
216
+ <Search className="w-5 h-5 text-gray-500" />
217
+ <input
218
+ ref={inputRef}
219
+ type="text"
220
+ value={query}
221
+ onChange={(e) => setQuery(e.target.value)}
222
+ onKeyDown={handleKeyDown}
223
+ placeholder="Search in files..."
224
+ className="flex-1 bg-transparent text-white placeholder-gray-500 outline-none text-sm"
225
+ autoComplete="off"
226
+ spellCheck={false}
227
+ />
228
+
229
+ {/* Options */}
230
+ <button
231
+ onClick={() => setCaseSensitive(!caseSensitive)}
232
+ className={cn(
233
+ 'p-1.5 rounded transition-colors',
234
+ caseSensitive
235
+ ? 'bg-purple-500/20 text-purple-300'
236
+ : 'text-gray-500 hover:bg-white/10'
237
+ )}
238
+ title="Case Sensitive"
239
+ aria-label="Toggle case sensitivity"
240
+ aria-pressed={caseSensitive}
241
+ >
242
+ <CaseSensitive className="w-4 h-4" aria-hidden="true" />
243
+ </button>
244
+ <button
245
+ onClick={() => setUseRegex(!useRegex)}
246
+ className={cn(
247
+ 'p-1.5 rounded transition-colors',
248
+ useRegex
249
+ ? 'bg-purple-500/20 text-purple-300'
250
+ : 'text-gray-500 hover:bg-white/10'
251
+ )}
252
+ title="Use Regex"
253
+ aria-label="Toggle regex mode"
254
+ aria-pressed={useRegex}
255
+ >
256
+ <Regex className="w-4 h-4" aria-hidden="true" />
257
+ </button>
258
+
259
+ {query && (
260
+ <button
261
+ onClick={() => setQuery('')}
262
+ className="p-1 hover:bg-white/10 rounded transition-colors"
263
+ >
264
+ <X className="w-4 h-4 text-gray-500" />
265
+ </button>
266
+ )}
267
+ <div className="text-xs text-gray-600 border border-white/10 px-1.5 py-0.5 rounded">
268
+ esc
269
+ </div>
270
+ </div>
271
+
272
+ {/* Results */}
273
+ <div ref={listRef} className="max-h-[60vh] overflow-y-auto">
274
+ {isLoading ? (
275
+ <div className="px-4 py-8 text-center text-gray-500 text-sm">
276
+ Searching...
277
+ </div>
278
+ ) : results.length === 0 ? (
279
+ <div className="px-4 py-8 text-center text-gray-500 text-sm">
280
+ {query ? 'No results found' : 'Type to search in files...'}
281
+ </div>
282
+ ) : (
283
+ <div className="divide-y divide-white/5">
284
+ {results.map((result) => {
285
+ const isExpanded = expandedFiles.has(result.filePath);
286
+ return (
287
+ <div key={result.filePath}>
288
+ {/* File Header */}
289
+ <button
290
+ onClick={() => toggleFile(result.filePath)}
291
+ className="w-full flex items-center gap-2 px-4 py-2 hover:bg-white/5 transition-colors text-left"
292
+ >
293
+ {isExpanded ? (
294
+ <ChevronDown className="w-4 h-4 text-gray-500" />
295
+ ) : (
296
+ <ChevronRight className="w-4 h-4 text-gray-500" />
297
+ )}
298
+ {getFileIcon(result.fileName)}
299
+ <span className="text-sm text-white truncate flex-1">
300
+ {result.fileName}
301
+ </span>
302
+ <span className="text-xs text-gray-500 truncate max-w-[200px]">
303
+ {result.relativePath}
304
+ </span>
305
+ <span className="text-xs text-purple-400 ml-2">
306
+ {result.matches.length} match{result.matches.length !== 1 && 'es'}
307
+ </span>
308
+ </button>
309
+
310
+ {/* Matches */}
311
+ {isExpanded && (
312
+ <div className="bg-black/20">
313
+ {result.matches.slice(0, 10).map((match, idx) => (
314
+ <button
315
+ key={`${result.filePath}-${match.line}-${idx}`}
316
+ onClick={() => handleMatchClick(result.filePath, match.line)}
317
+ className="w-full px-4 py-1.5 pl-10 text-left hover:bg-white/5 transition-colors"
318
+ >
319
+ <HighlightedMatch match={match} query={query} />
320
+ </button>
321
+ ))}
322
+ {result.matches.length > 10 && (
323
+ <div className="px-4 py-1.5 pl-10 text-xs text-gray-500">
324
+ +{result.matches.length - 10} more matches
325
+ </div>
326
+ )}
327
+ </div>
328
+ )}
329
+ </div>
330
+ );
331
+ })}
332
+ </div>
333
+ )}
334
+ </div>
335
+
336
+ {/* Footer */}
337
+ <div className="flex items-center justify-between px-4 py-2 border-t border-white/10 text-xs text-gray-500">
338
+ <div className="flex items-center gap-4">
339
+ <span>Click to open file at line</span>
340
+ </div>
341
+ <div>
342
+ {totalMatches > 0 && `${totalMatches} matches in ${results.length} files`}
343
+ </div>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ );
348
+ }
@@ -0,0 +1,241 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { useUIStore } from '@/lib/stores/uiStore';
5
+ import { useProjectStore } from '@/lib/stores/projectStore';
6
+ import { useFileStore } from '@/lib/stores/fileStore';
7
+ import { Search, File, FileText, FileCode, FileJson, X } from 'lucide-react';
8
+ import { cn } from '@/lib/utils';
9
+ import { useFocusTrap } from '@/hooks/useFocusTrap';
10
+
11
+ interface FileResult {
12
+ filePath: string;
13
+ relativePath: string;
14
+ fileName: string;
15
+ extension: string;
16
+ }
17
+
18
+ const getFileIcon = (extension: string) => {
19
+ switch (extension) {
20
+ case '.md':
21
+ return <FileText className="w-4 h-4 text-blue-400" />;
22
+ case '.json':
23
+ case '.yaml':
24
+ case '.yml':
25
+ return <FileJson className="w-4 h-4 text-yellow-400" />;
26
+ case '.ts':
27
+ case '.tsx':
28
+ case '.js':
29
+ case '.jsx':
30
+ return <FileCode className="w-4 h-4 text-green-400" />;
31
+ default:
32
+ return <File className="w-4 h-4 text-gray-400" />;
33
+ }
34
+ };
35
+
36
+ export function QuickOpen() {
37
+ const [query, setQuery] = useState('');
38
+ const [results, setResults] = useState<FileResult[]>([]);
39
+ const [selectedIndex, setSelectedIndex] = useState(0);
40
+ const [isLoading, setIsLoading] = useState(false);
41
+ const inputRef = useRef<HTMLInputElement>(null);
42
+ const listRef = useRef<HTMLDivElement>(null);
43
+ const modalRef = useRef<HTMLDivElement>(null);
44
+
45
+ const { activeModal, closeModal } = useUIStore();
46
+ const { currentProject } = useProjectStore();
47
+ const { openFile } = useFileStore();
48
+
49
+ const isOpen = activeModal === 'quickOpen';
50
+
51
+ // Focus trap for accessibility
52
+ useFocusTrap(modalRef, isOpen, {
53
+ onEscape: closeModal,
54
+ autoFocus: false, // We handle focus manually to the input
55
+ });
56
+
57
+ // Focus input when opened
58
+ useEffect(() => {
59
+ if (isOpen) {
60
+ setQuery('');
61
+ setResults([]);
62
+ setSelectedIndex(0);
63
+ setTimeout(() => inputRef.current?.focus(), 50);
64
+ }
65
+ }, [isOpen]);
66
+
67
+ // Search files
68
+ const searchFiles = useCallback(async (searchQuery: string) => {
69
+ if (!currentProject) return;
70
+
71
+ setIsLoading(true);
72
+ try {
73
+ const params = new URLSearchParams({
74
+ projectPath: currentProject.path,
75
+ query: searchQuery,
76
+ type: 'files',
77
+ limit: '50',
78
+ });
79
+
80
+ const response = await fetch(`/api/search?${params}`);
81
+ const data = await response.json();
82
+
83
+ if (data.results) {
84
+ setResults(data.results);
85
+ setSelectedIndex(0);
86
+ }
87
+ } catch (error) {
88
+ console.error('Search error:', error);
89
+ } finally {
90
+ setIsLoading(false);
91
+ }
92
+ }, [currentProject]);
93
+
94
+ // Debounced search
95
+ useEffect(() => {
96
+ if (!isOpen) return;
97
+
98
+ const timer = setTimeout(() => {
99
+ searchFiles(query);
100
+ }, 150);
101
+
102
+ return () => clearTimeout(timer);
103
+ }, [query, isOpen, searchFiles]);
104
+
105
+ // Keyboard navigation
106
+ const handleKeyDown = (e: React.KeyboardEvent) => {
107
+ switch (e.key) {
108
+ case 'ArrowDown':
109
+ e.preventDefault();
110
+ setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
111
+ break;
112
+ case 'ArrowUp':
113
+ e.preventDefault();
114
+ setSelectedIndex((i) => Math.max(i - 1, 0));
115
+ break;
116
+ case 'Enter':
117
+ e.preventDefault();
118
+ if (results[selectedIndex]) {
119
+ handleSelect(results[selectedIndex]);
120
+ }
121
+ break;
122
+ case 'Escape':
123
+ e.preventDefault();
124
+ closeModal();
125
+ break;
126
+ }
127
+ };
128
+
129
+ // Scroll selected item into view
130
+ useEffect(() => {
131
+ const listElement = listRef.current;
132
+ if (!listElement) return;
133
+
134
+ const selectedElement = listElement.children[selectedIndex] as HTMLElement;
135
+ if (selectedElement) {
136
+ selectedElement.scrollIntoView({ block: 'nearest' });
137
+ }
138
+ }, [selectedIndex]);
139
+
140
+ const handleSelect = (file: FileResult) => {
141
+ openFile(file.filePath);
142
+ closeModal();
143
+ };
144
+
145
+ if (!isOpen) return null;
146
+
147
+ return (
148
+ <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
149
+ {/* Backdrop */}
150
+ <div
151
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
152
+ onClick={closeModal}
153
+ aria-hidden="true"
154
+ />
155
+
156
+ {/* Modal */}
157
+ <div
158
+ ref={modalRef}
159
+ className="relative w-full max-w-2xl bg-[#12121a] border border-white/10 rounded-xl shadow-2xl overflow-hidden"
160
+ role="dialog"
161
+ aria-modal="true"
162
+ aria-label="Quick open file"
163
+ >
164
+ {/* Search Input */}
165
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-white/10">
166
+ <Search className="w-5 h-5 text-gray-500" />
167
+ <input
168
+ ref={inputRef}
169
+ type="text"
170
+ value={query}
171
+ onChange={(e) => setQuery(e.target.value)}
172
+ onKeyDown={handleKeyDown}
173
+ placeholder="Search files by name..."
174
+ className="flex-1 bg-transparent text-white placeholder-gray-500 outline-none text-sm"
175
+ autoComplete="off"
176
+ spellCheck={false}
177
+ />
178
+ {query && (
179
+ <button
180
+ onClick={() => setQuery('')}
181
+ className="p-1 hover:bg-white/10 rounded transition-colors"
182
+ >
183
+ <X className="w-4 h-4 text-gray-500" />
184
+ </button>
185
+ )}
186
+ <div className="text-xs text-gray-600 border border-white/10 px-1.5 py-0.5 rounded">
187
+ esc
188
+ </div>
189
+ </div>
190
+
191
+ {/* Results */}
192
+ <div ref={listRef} className="max-h-[50vh] overflow-y-auto" role="listbox" aria-label="Search results">
193
+ {isLoading && results.length === 0 ? (
194
+ <div className="px-4 py-8 text-center text-gray-500 text-sm">
195
+ Searching...
196
+ </div>
197
+ ) : results.length === 0 ? (
198
+ <div className="px-4 py-8 text-center text-gray-500 text-sm">
199
+ {query ? 'No files found' : 'Type to search files...'}
200
+ </div>
201
+ ) : (
202
+ results.map((file, index) => (
203
+ <button
204
+ key={file.filePath}
205
+ onClick={() => handleSelect(file)}
206
+ className={cn(
207
+ 'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
208
+ index === selectedIndex
209
+ ? 'bg-purple-500/20 border-l-2 border-purple-500'
210
+ : 'hover:bg-white/5 border-l-2 border-transparent'
211
+ )}
212
+ role="option"
213
+ aria-selected={index === selectedIndex}
214
+ tabIndex={index === selectedIndex ? 0 : -1}
215
+ >
216
+ {getFileIcon(file.extension)}
217
+ <div className="flex-1 min-w-0">
218
+ <div className="text-sm text-white truncate">{file.fileName}</div>
219
+ <div className="text-xs text-gray-500 truncate">
220
+ {file.relativePath}
221
+ </div>
222
+ </div>
223
+ </button>
224
+ ))
225
+ )}
226
+ </div>
227
+
228
+ {/* Footer */}
229
+ <div className="flex items-center justify-between px-4 py-2 border-t border-white/10 text-xs text-gray-500">
230
+ <div className="flex items-center gap-4">
231
+ <span>↑↓ navigate</span>
232
+ <span>↵ open</span>
233
+ </div>
234
+ <div>
235
+ {results.length > 0 && `${results.length} files`}
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ );
241
+ }