@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.
- package/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- 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
|
+
}
|