@dyyz1993/create-agent 2.0.1 → 2.1.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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/commands/create.ts +31 -31
  3. package/src/commands/workspace.ts +112 -105
  4. package/src/lib/copy.ts +1 -0
  5. package/templates/agent/electron/main.js +46 -0
  6. package/templates/agent/electron/preload.js +5 -0
  7. package/templates/agent/electron-builder.json +40 -0
  8. package/templates/agent/eslint.config.mjs +2 -0
  9. package/templates/agent/package.json +60 -2
  10. package/templates/agent/src/mainview/App.tsx +29 -26
  11. package/templates/agent/src/mainview/components/chat/ChatPanel.tsx +88 -88
  12. package/templates/agent/src/mainview/components/file-preview/VirtualizedCodeView.tsx +105 -81
  13. package/templates/agent/src/mainview/components/search/SearchPanel.tsx +427 -378
  14. package/templates/agent/src/mainview/components/todo/TodoPanel.tsx +3 -3
  15. package/templates/agent/src/mainview/hooks/use-input-history.ts +70 -61
  16. package/templates/agent/src/mainview/lib/api-client.ts +1 -4
  17. package/templates/agent/src/mainview/main.tsx +4 -10
  18. package/templates/agent/src/mainview/stores/use-feed-store.ts +107 -107
  19. package/templates/agent/src/mainview/utils/drop-handler.ts +114 -115
  20. package/templates/agent/src/server-config.ts +1 -1
  21. package/templates/agent/src/server.ts +1 -2
  22. package/templates/agent/src/shared/handlers/chat.ts +5 -5
  23. package/templates/agent/src/shared/handlers/debug.ts +5 -1
  24. package/templates/agent/src/shared/handlers/git.ts +286 -243
  25. package/templates/agent/src/shared/http-routes.ts +1 -1
  26. package/templates/agent/src/shared/lib/bash-security.ts +43 -43
  27. package/templates/agent/tsconfig.ipc.json +5 -1
  28. package/templates/agent/tsconfig.json +3 -1
  29. package/templates/chat/package.json +3 -0
  30. package/templates/chat/src/mainview/hooks/use-input-history.ts +70 -61
  31. package/templates/chat/src/mainview/lib/api-client.ts +2 -5
  32. package/templates/chat/src/mainview/main.tsx +10 -7
  33. package/templates/chat/src/server-config.ts +1 -1
  34. package/templates/chat/src/server.ts +1 -2
  35. package/templates/chat/src/shared/handlers/chat.ts +5 -5
  36. package/templates/chat/src/shared/handlers/debug.ts +5 -1
  37. package/templates/chat/src/shared/http-routes.ts +1 -1
  38. package/templates/chat/tsconfig.ipc.json +5 -1
  39. package/templates/chat/tsconfig.json +12 -2
  40. package/templates/general/package.json +3 -0
  41. package/templates/general/src/mainview/components/file-preview/VirtualizedCodeView.tsx +101 -81
  42. package/templates/general/src/mainview/components/search/SearchPanel.tsx +429 -378
  43. package/templates/general/src/mainview/hooks/use-input-history.ts +70 -61
  44. package/templates/general/src/mainview/lib/api-client.ts +2 -5
  45. package/templates/general/src/mainview/main.tsx +10 -7
  46. package/templates/general/src/mainview/stores/use-feed-store.ts +107 -107
  47. package/templates/general/src/mainview/utils/drop-handler.ts +114 -115
  48. package/templates/general/src/server-config.ts +1 -1
  49. package/templates/general/src/server.ts +1 -2
  50. package/templates/general/src/shared/handlers/chat.ts +5 -5
  51. package/templates/general/src/shared/handlers/debug.ts +5 -1
  52. package/templates/general/src/shared/handlers/git.ts +286 -243
  53. package/templates/general/src/shared/http-routes.ts +1 -1
  54. package/templates/general/tsconfig.ipc.json +5 -1
  55. package/templates/general/tsconfig.json +12 -2
  56. package/templates/shared/components/ErrorBoundary.tsx +50 -49
  57. package/templates/shared/http-routes.ts +210 -190
@@ -1,404 +1,453 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import {
3
- Search,
4
- CaseSensitive,
5
- WholeWord,
6
- Regex,
7
- FileText,
8
- ChevronRight,
9
- ChevronDown,
10
- Loader2,
11
- X,
3
+ Search,
4
+ CaseSensitive,
5
+ WholeWord,
6
+ Regex,
7
+ FileText,
8
+ ChevronRight,
9
+ ChevronDown,
10
+ Loader2,
11
+ X,
12
12
  } from "lucide-react";
13
13
  import { apiClient } from "../../lib/api-client";
14
14
  import { useExplorerStore } from "../../stores/use-explorer-store";
15
15
  import type { TreeNode } from "../../types";
16
16
 
17
17
  interface SearchMatch {
18
- file: string;
19
- line: number;
20
- column: number;
21
- text: string;
22
- highlightStart: number;
23
- highlightEnd: number;
18
+ file: string;
19
+ line: number;
20
+ column: number;
21
+ text: string;
22
+ highlightStart: number;
23
+ highlightEnd: number;
24
24
  }
25
25
 
26
26
  interface FileGroup {
27
- filePath: string;
28
- relativePath: string;
29
- matches: SearchMatch[];
30
- expanded: boolean;
27
+ filePath: string;
28
+ relativePath: string;
29
+ matches: SearchMatch[];
30
+ expanded: boolean;
31
31
  }
32
32
 
33
33
  const SKIP_DIRS = new Set([
34
- "node_modules",
35
- ".git",
36
- "dist",
37
- "build",
38
- ".next",
39
- ".nuxt",
40
- "coverage",
41
- ".cache",
42
- ".turbo",
43
- ".vercel",
44
- "out",
45
- ".output",
34
+ "node_modules",
35
+ ".git",
36
+ "dist",
37
+ "build",
38
+ ".next",
39
+ ".nuxt",
40
+ "coverage",
41
+ ".cache",
42
+ ".turbo",
43
+ ".vercel",
44
+ "out",
45
+ ".output",
46
46
  ]);
47
47
 
48
48
  const TEXT_EXTENSIONS = new Set([
49
- "ts", "tsx", "js", "jsx", "mjs", "cjs",
50
- "json", "md", "mdx", "yaml", "yml", "toml",
51
- "css", "scss", "less", "sass",
52
- "html", "htm", "xml", "svg",
53
- "py", "rb", "go", "rs", "java", "kt", "swift", "c", "cpp", "h", "hpp",
54
- "sh", "bash", "zsh", "fish",
55
- "sql", "graphql", "gql",
56
- "txt", "csv", "env", "gitignore", "eslintrc", "prettierrc",
57
- "conf", "ini", "cfg", "log",
58
- "vue", "svelte",
49
+ "ts",
50
+ "tsx",
51
+ "js",
52
+ "jsx",
53
+ "mjs",
54
+ "cjs",
55
+ "json",
56
+ "md",
57
+ "mdx",
58
+ "yaml",
59
+ "yml",
60
+ "toml",
61
+ "css",
62
+ "scss",
63
+ "less",
64
+ "sass",
65
+ "html",
66
+ "htm",
67
+ "xml",
68
+ "svg",
69
+ "py",
70
+ "rb",
71
+ "go",
72
+ "rs",
73
+ "java",
74
+ "kt",
75
+ "swift",
76
+ "c",
77
+ "cpp",
78
+ "h",
79
+ "hpp",
80
+ "sh",
81
+ "bash",
82
+ "zsh",
83
+ "fish",
84
+ "sql",
85
+ "graphql",
86
+ "gql",
87
+ "txt",
88
+ "csv",
89
+ "env",
90
+ "gitignore",
91
+ "eslintrc",
92
+ "prettierrc",
93
+ "conf",
94
+ "ini",
95
+ "cfg",
96
+ "log",
97
+ "vue",
98
+ "svelte",
59
99
  ]);
60
100
 
61
101
  function isTextFile(name: string): boolean {
62
- const dotIdx = name.lastIndexOf(".");
63
- if (dotIdx === -1) return false;
64
- const ext = name.slice(dotIdx + 1).toLowerCase();
65
- if (TEXT_EXTENSIONS.has(ext)) return true;
66
- if (name.startsWith(".") && TEXT_EXTENSIONS.has(name.slice(1))) return true;
67
- return false;
102
+ const dotIdx = name.lastIndexOf(".");
103
+ if (dotIdx === -1) return false;
104
+ const ext = name.slice(dotIdx + 1).toLowerCase();
105
+ if (TEXT_EXTENSIONS.has(ext)) return true;
106
+ if (name.startsWith(".") && TEXT_EXTENSIONS.has(name.slice(1))) return true;
107
+ return false;
68
108
  }
69
109
 
70
- function buildSearchRegex(query: string, caseSensitive: boolean, wholeWord: boolean, useRegex: boolean): RegExp {
71
- let pattern = useRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
- if (wholeWord) pattern = `\\b${pattern}\\b`;
73
- return new RegExp(pattern, caseSensitive ? "g" : "gi");
110
+ function buildSearchRegex(
111
+ query: string,
112
+ caseSensitive: boolean,
113
+ wholeWord: boolean,
114
+ useRegex: boolean
115
+ ): RegExp {
116
+ let pattern = useRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
117
+ if (wholeWord) pattern = `\\b${pattern}\\b`;
118
+ return new RegExp(pattern, caseSensitive ? "g" : "gi");
74
119
  }
75
120
 
76
121
  export function SearchPanel() {
77
- const [query, setQuery] = useState("");
78
- const [caseSensitive, setCaseSensitive] = useState(false);
79
- const [wholeWord, setWholeWord] = useState(false);
80
- const [useRegex, setUseRegex] = useState(false);
81
- const [results, setResults] = useState<FileGroup[]>([]);
82
- const [isSearching, setIsSearching] = useState(false);
83
- const [totalMatches, setTotalMatches] = useState(0);
84
- const [filesSearched, setFilesSearched] = useState(0);
85
- const [hasSearched, setHasSearched] = useState(false);
86
- const inputRef = useRef<HTMLInputElement>(null);
87
- const abortRef = useRef(false);
88
-
89
- const currentPath = useExplorerStore((s) => s.currentPath);
90
-
91
- useEffect(() => {
92
- inputRef.current?.focus();
93
- }, []);
94
-
95
- const collectFiles = useCallback(async (dirPath: string): Promise<string[]> => {
96
- const files: string[] = [];
97
- const queue = [dirPath];
98
-
99
- while (queue.length > 0 && !abortRef.current) {
100
- const current = queue.shift()!;
101
- try {
102
- const res = await apiClient.call("file.listDir", { path: current });
103
- for (const entry of res.entries) {
104
- if (abortRef.current) break;
105
- if (entry.name.startsWith(".") && entry.name !== ".env" && entry.name !== ".env.local") continue;
106
- if (entry.type === "directory") {
107
- if (!SKIP_DIRS.has(entry.name)) queue.push(entry.path);
108
- } else if (isTextFile(entry.name)) {
109
- files.push(entry.path);
110
- }
111
- }
112
- } catch {
113
- // skip unreadable dirs
114
- }
115
- }
116
- return files;
117
- }, []);
118
-
119
- const performSearch = useCallback(async () => {
120
- if (!query.trim() || !currentPath) return;
121
-
122
- abortRef.current = false;
123
- setIsSearching(true);
124
- setHasSearched(true);
125
- setResults([]);
126
- setTotalMatches(0);
127
-
128
- try {
129
- const regex = buildSearchRegex(query.trim(), caseSensitive, wholeWord, useRegex);
130
- const filePaths = await collectFiles(currentPath);
131
- const matchMap = new Map<string, SearchMatch[]>();
132
- let matchCount = 0;
133
-
134
- for (const fp of filePaths) {
135
- if (abortRef.current) break;
136
- try {
137
- const res = await apiClient.call("file.readFile", { path: fp });
138
- const lines = res.content.split("\n");
139
- for (let i = 0; i < lines.length; i++) {
140
- const line = lines[i];
141
- regex.lastIndex = 0;
142
- const m = regex.exec(line);
143
- if (m) {
144
- if (!matchMap.has(fp)) matchMap.set(fp, []);
145
- matchMap.get(fp)!.push({
146
- file: fp,
147
- line: i + 1,
148
- column: m.index + 1,
149
- text: line,
150
- highlightStart: m.index,
151
- highlightEnd: m.index + m[0].length,
152
- });
153
- matchCount++;
154
- }
155
- }
156
- } catch {
157
- // skip unreadable files
158
- }
159
- }
160
-
161
- setFilesSearched(filePaths.length);
162
-
163
- const rootPath = currentPath.endsWith("/") ? currentPath : currentPath + "/";
164
- const groups: FileGroup[] = [];
165
- for (const [filePath, matches] of matchMap) {
166
- groups.push({
167
- filePath,
168
- relativePath: filePath.startsWith(rootPath) ? filePath.slice(rootPath.length) : filePath,
169
- matches,
170
- expanded: true,
171
- });
172
- }
173
-
174
- setResults(groups);
175
- setTotalMatches(matchCount);
176
- } catch {
177
- // search error
178
- } finally {
179
- setIsSearching(false);
180
- }
181
- }, [query, caseSensitive, wholeWord, useRegex, currentPath, collectFiles]);
182
-
183
- const cancelSearch = useCallback(() => {
184
- abortRef.current = true;
185
- }, []);
186
-
187
- const handleKeyDown = useCallback(
188
- (e: React.KeyboardEvent) => {
189
- if (e.key === "Enter" && !isSearching) {
190
- performSearch();
191
- }
192
- if (e.key === "Escape") {
193
- setQuery("");
194
- inputRef.current?.focus();
195
- }
196
- },
197
- [performSearch, isSearching],
198
- );
199
-
200
- const toggleGroup = useCallback((index: number) => {
201
- setResults((prev) => prev.map((g, i) => (i === index ? { ...g, expanded: !g.expanded } : g)));
202
- }, []);
203
-
204
- const handleOpenFile = useCallback((filePath: string, _line: number) => {
205
- const node: TreeNode = {
206
- name: filePath.split("/").pop() || filePath,
207
- path: filePath,
208
- type: "file",
209
- };
210
- useExplorerStore.getState().openFile(node);
211
- }, []);
212
-
213
- const modKey = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) ? "⌘" : "Ctrl";
214
-
215
- return (
216
- <div className="flex flex-col h-full bg-[var(--color-bg-primary)]">
217
- {/* Header */}
218
- <div className="px-3 py-2 text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wide border-b border-[var(--color-border-primary)] flex items-center justify-between flex-shrink-0">
219
- <div className="flex items-center gap-1.5">
220
- <Search className="w-3.5 h-3.5" />
221
- Search
222
- </div>
223
- <kbd className="px-1.5 py-0.5 text-[10px] text-[var(--color-text-placeholder)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-primary)] rounded font-mono">
224
- {modKey}⇧F
225
- </kbd>
226
- </div>
227
-
228
- {/* Search input */}
229
- <div className="p-2 border-b border-[var(--color-border-primary)] flex-shrink-0">
230
- <div className="relative">
231
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-placeholder)]" />
232
- <input
233
- ref={inputRef}
234
- type="text"
235
- value={query}
236
- onChange={(e) => setQuery(e.target.value)}
237
- onKeyDown={handleKeyDown}
238
- placeholder="Search in files..."
239
- className="w-full pl-7 pr-7 py-1.5 text-xs bg-[var(--color-bg-secondary)] rounded text-[var(--color-text-primary)] border border-[var(--color-border-secondary)] focus:border-[var(--color-accent)] focus:outline-none"
240
- />
241
- {query && (
242
- <button
243
- onClick={() => {
244
- setQuery("");
245
- inputRef.current?.focus();
246
- }}
247
- className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)]"
248
- >
249
- <X className="w-3 h-3" />
250
- </button>
251
- )}
252
- </div>
253
-
254
- {/* Toggle buttons */}
255
- <div className="flex items-center gap-0.5 mt-1.5">
256
- <button
257
- onClick={() => setCaseSensitive((v) => !v)}
258
- className={`p-1 rounded text-xs transition-colors ${
259
- caseSensitive
260
- ? "bg-[var(--color-accent)]/30 text-[var(--color-text-accent)] border border-[var(--color-accent)]/50"
261
- : "text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)] border border-transparent"
262
- }`}
263
- title="Match Case"
264
- >
265
- <CaseSensitive className="w-3.5 h-3.5" />
266
- </button>
267
- <button
268
- onClick={() => setWholeWord((v) => !v)}
269
- className={`p-1 rounded text-xs transition-colors ${
270
- wholeWord
271
- ? "bg-[var(--color-accent)]/30 text-[var(--color-text-accent)] border border-[var(--color-accent)]/50"
272
- : "text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)] border border-transparent"
273
- }`}
274
- title="Match Whole Word"
275
- >
276
- <WholeWord className="w-3.5 h-3.5" />
277
- </button>
278
- <button
279
- onClick={() => setUseRegex((v) => !v)}
280
- className={`p-1 rounded text-xs transition-colors ${
281
- useRegex
282
- ? "bg-[var(--color-accent)]/30 text-[var(--color-text-accent)] border border-[var(--color-accent)]/50"
283
- : "text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)] border border-transparent"
284
- }`}
285
- title="Use Regular Expression"
286
- >
287
- <Regex className="w-3.5 h-3.5" />
288
- </button>
289
-
290
- <div className="flex-1" />
291
-
292
- {isSearching ? (
293
- <button
294
- onClick={cancelSearch}
295
- className="px-2 py-1 text-xs text-[var(--color-text-error)] hover:text-red-300 transition-colors flex items-center gap-1"
296
- >
297
- <Loader2 className="w-3 h-3 animate-spin" />
298
- Cancel
299
- </button>
300
- ) : (
301
- query.trim() && (
302
- <button
303
- onClick={performSearch}
304
- className="px-2 py-1 text-xs bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] rounded transition-colors"
305
- >
306
- Search
307
- </button>
308
- )
309
- )}
310
- </div>
311
- </div>
312
-
313
- {/* Results header */}
314
- {hasSearched && !isSearching && (
315
- <div className="px-3 py-1.5 text-[11px] text-[var(--color-text-placeholder)] border-b border-[var(--color-bg-secondary)] flex-shrink-0">
316
- {totalMatches > 0 ? (
317
- <>
318
- <span className="text-[var(--color-text-accent)] font-medium">{totalMatches}</span> result{totalMatches !== 1 && "s"} in{" "}
319
- <span className="text-[var(--color-text-accent)] font-medium">{results.length}</span> file
320
- {results.length !== 1 && "s"}
321
- <span className="ml-2 text-gray-600">({filesSearched} files searched)</span>
322
- </>
323
- ) : (
324
- "No results found"
325
- )}
326
- </div>
327
- )}
328
-
329
- {/* Loading state */}
330
- {isSearching && (
331
- <div className="flex items-center justify-center py-8 text-[var(--color-text-placeholder)] text-sm flex-shrink-0">
332
- <Loader2 className="w-4 h-4 animate-spin mr-2" />
333
- Searching...
334
- </div>
335
- )}
336
-
337
- {/* Results list */}
338
- <div className="flex-1 overflow-y-auto">
339
- {!hasSearched && !isSearching ? (
340
- <div className="flex flex-col items-center justify-center h-full text-gray-600 text-xs px-4">
341
- <Search className="w-8 h-8 mb-2 text-gray-700" />
342
- <p>Type a search term and press Enter</p>
343
- <p className="mt-1 text-gray-700">
344
- {modKey}+Shift+F to focus search
345
- </p>
346
- </div>
347
- ) : !isSearching && totalMatches === 0 ? (
348
- <div className="flex flex-col items-center justify-center h-full text-gray-600 text-xs px-4">
349
- <FileText className="w-8 h-8 mb-2 text-gray-700" />
350
- <p>No results for "{query}"</p>
351
- <p className="mt-1 text-gray-700">Try different search terms</p>
352
- </div>
353
- ) : (
354
- <div className="py-1">
355
- {results.map((group, gi) => (
356
- <div key={group.filePath}>
357
- {/* File group header */}
358
- <button
359
- onClick={() => toggleGroup(gi)}
360
- className="w-full flex items-center gap-1 px-2 py-1 text-xs hover:bg-[var(--color-bg-secondary)] transition-colors text-left"
361
- >
362
- {group.expanded ? (
363
- <ChevronDown className="w-3 h-3 text-[var(--color-text-placeholder)] flex-shrink-0" />
364
- ) : (
365
- <ChevronRight className="w-3 h-3 text-[var(--color-text-placeholder)] flex-shrink-0" />
366
- )}
367
- <FileText className="w-3.5 h-3.5 text-[var(--color-text-placeholder)] flex-shrink-0" />
368
- <span className="text-[var(--color-text-secondary)] truncate flex-1">{group.relativePath}</span>
369
- <span className="text-[10px] text-gray-600 flex-shrink-0 ml-1">
370
- {group.matches.length}
371
- </span>
372
- </button>
373
-
374
- {/* Individual matches */}
375
- {group.expanded && (
376
- <div className="ml-4">
377
- {group.matches.map((match, mi) => (
378
- <button
379
- key={mi}
380
- onClick={() => handleOpenFile(match.file, match.line)}
381
- className="w-full text-left px-2 py-0.5 text-[11px] hover:bg-[var(--color-bg-secondary)] transition-colors group flex items-start gap-2"
382
- >
383
- <span className="text-gray-600 w-6 text-right flex-shrink-0 select-none pt-px">
384
- {match.line}
385
- </span>
386
- <span className="text-[var(--color-text-tertiary)] truncate font-mono leading-relaxed">
387
- {match.text.slice(0, match.highlightStart)}
388
- <span className="bg-[var(--color-accent)]/40 text-[var(--color-text-accent)]">
389
- {match.text.slice(match.highlightStart, match.highlightEnd)}
390
- </span>
391
- {match.text.slice(match.highlightEnd)}
392
- </span>
393
- </button>
394
- ))}
395
- </div>
396
- )}
397
- </div>
398
- ))}
399
- </div>
400
- )}
401
- </div>
402
- </div>
403
- );
122
+ const [query, setQuery] = useState("");
123
+ const [caseSensitive, setCaseSensitive] = useState(false);
124
+ const [wholeWord, setWholeWord] = useState(false);
125
+ const [useRegex, setUseRegex] = useState(false);
126
+ const [results, setResults] = useState<FileGroup[]>([]);
127
+ const [isSearching, setIsSearching] = useState(false);
128
+ const [totalMatches, setTotalMatches] = useState(0);
129
+ const [filesSearched, setFilesSearched] = useState(0);
130
+ const [hasSearched, setHasSearched] = useState(false);
131
+ const inputRef = useRef<HTMLInputElement>(null);
132
+ const abortRef = useRef(false);
133
+
134
+ const currentPath = useExplorerStore((s) => s.currentPath);
135
+
136
+ useEffect(() => {
137
+ inputRef.current?.focus();
138
+ }, []);
139
+
140
+ const collectFiles = useCallback(async (dirPath: string): Promise<string[]> => {
141
+ const files: string[] = [];
142
+ const queue = [dirPath];
143
+
144
+ while (queue.length > 0 && !abortRef.current) {
145
+ const current = queue.shift()!;
146
+ try {
147
+ const res = await apiClient.call("file.listDir", { path: current });
148
+ for (const entry of res.entries) {
149
+ if (abortRef.current) break;
150
+ if (entry.name.startsWith(".") && entry.name !== ".env" && entry.name !== ".env.local")
151
+ continue;
152
+ if (entry.type === "directory") {
153
+ if (!SKIP_DIRS.has(entry.name)) queue.push(entry.path);
154
+ } else if (isTextFile(entry.name)) {
155
+ files.push(entry.path);
156
+ }
157
+ }
158
+ } catch {
159
+ // skip unreadable dirs
160
+ }
161
+ }
162
+ return files;
163
+ }, []);
164
+
165
+ const performSearch = useCallback(async () => {
166
+ if (!query.trim() || !currentPath) return;
167
+
168
+ abortRef.current = false;
169
+ setIsSearching(true);
170
+ setHasSearched(true);
171
+ setResults([]);
172
+ setTotalMatches(0);
173
+
174
+ try {
175
+ const regex = buildSearchRegex(query.trim(), caseSensitive, wholeWord, useRegex);
176
+ const filePaths = await collectFiles(currentPath);
177
+ const matchMap = new Map<string, SearchMatch[]>();
178
+ let matchCount = 0;
179
+
180
+ for (const fp of filePaths) {
181
+ if (abortRef.current) break;
182
+ try {
183
+ const res = await apiClient.call("file.readFile", { path: fp });
184
+ const lines = res.content.split("\n");
185
+ for (let i = 0; i < lines.length; i++) {
186
+ const line = lines[i]!;
187
+ regex.lastIndex = 0;
188
+ const m = regex.exec(line);
189
+ if (m) {
190
+ if (!matchMap.has(fp)) matchMap.set(fp, []);
191
+ matchMap.get(fp)!.push({
192
+ file: fp,
193
+ line: i + 1,
194
+ column: m.index + 1,
195
+ text: line!,
196
+ highlightStart: m.index,
197
+ highlightEnd: m.index + m[0]!.length,
198
+ });
199
+ matchCount++;
200
+ }
201
+ }
202
+ } catch {
203
+ // skip unreadable files
204
+ }
205
+ }
206
+
207
+ setFilesSearched(filePaths.length);
208
+
209
+ const rootPath = currentPath.endsWith("/") ? currentPath : currentPath + "/";
210
+ const groups: FileGroup[] = [];
211
+ for (const [filePath, matches] of matchMap) {
212
+ groups.push({
213
+ filePath,
214
+ relativePath: filePath.startsWith(rootPath) ? filePath.slice(rootPath.length) : filePath,
215
+ matches,
216
+ expanded: true,
217
+ });
218
+ }
219
+
220
+ setResults(groups);
221
+ setTotalMatches(matchCount);
222
+ } catch {
223
+ // search error
224
+ } finally {
225
+ setIsSearching(false);
226
+ }
227
+ }, [query, caseSensitive, wholeWord, useRegex, currentPath, collectFiles]);
228
+
229
+ const cancelSearch = useCallback(() => {
230
+ abortRef.current = true;
231
+ }, []);
232
+
233
+ const handleKeyDown = useCallback(
234
+ (e: React.KeyboardEvent) => {
235
+ if (e.key === "Enter" && !isSearching) {
236
+ performSearch();
237
+ }
238
+ if (e.key === "Escape") {
239
+ setQuery("");
240
+ inputRef.current?.focus();
241
+ }
242
+ },
243
+ [performSearch, isSearching]
244
+ );
245
+
246
+ const toggleGroup = useCallback((index: number) => {
247
+ setResults((prev) => prev.map((g, i) => (i === index ? { ...g, expanded: !g.expanded } : g)));
248
+ }, []);
249
+
250
+ const handleOpenFile = useCallback((filePath: string, _line: number) => {
251
+ const node: TreeNode = {
252
+ name: filePath.split("/").pop() || filePath,
253
+ path: filePath,
254
+ type: "file",
255
+ };
256
+ useExplorerStore.getState().openFile(node);
257
+ }, []);
258
+
259
+ const modKey =
260
+ typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) ? "⌘" : "Ctrl";
261
+
262
+ return (
263
+ <div className="flex flex-col h-full bg-[var(--color-bg-primary)]">
264
+ {/* Header */}
265
+ <div className="px-3 py-2 text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wide border-b border-[var(--color-border-primary)] flex items-center justify-between flex-shrink-0">
266
+ <div className="flex items-center gap-1.5">
267
+ <Search className="w-3.5 h-3.5" />
268
+ Search
269
+ </div>
270
+ <kbd className="px-1.5 py-0.5 text-[10px] text-[var(--color-text-placeholder)] bg-[var(--color-bg-secondary)] border border-[var(--color-border-primary)] rounded font-mono">
271
+ {modKey}⇧F
272
+ </kbd>
273
+ </div>
274
+
275
+ {/* Search input */}
276
+ <div className="p-2 border-b border-[var(--color-border-primary)] flex-shrink-0">
277
+ <div className="relative">
278
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-placeholder)]" />
279
+ <input
280
+ ref={inputRef}
281
+ type="text"
282
+ value={query}
283
+ onChange={(e) => setQuery(e.target.value)}
284
+ onKeyDown={handleKeyDown}
285
+ placeholder="Search in files..."
286
+ className="w-full pl-7 pr-7 py-1.5 text-xs bg-[var(--color-bg-secondary)] rounded text-[var(--color-text-primary)] border border-[var(--color-border-secondary)] focus:border-[var(--color-accent)] focus:outline-none"
287
+ />
288
+ {query && (
289
+ <button
290
+ onClick={() => {
291
+ setQuery("");
292
+ inputRef.current?.focus();
293
+ }}
294
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)]"
295
+ >
296
+ <X className="w-3 h-3" />
297
+ </button>
298
+ )}
299
+ </div>
300
+
301
+ {/* Toggle buttons */}
302
+ <div className="flex items-center gap-0.5 mt-1.5">
303
+ <button
304
+ onClick={() => setCaseSensitive((v) => !v)}
305
+ className={`p-1 rounded text-xs transition-colors ${
306
+ caseSensitive
307
+ ? "bg-[var(--color-accent)]/30 text-[var(--color-text-accent)] border border-[var(--color-accent)]/50"
308
+ : "text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)] border border-transparent"
309
+ }`}
310
+ title="Match Case"
311
+ >
312
+ <CaseSensitive className="w-3.5 h-3.5" />
313
+ </button>
314
+ <button
315
+ onClick={() => setWholeWord((v) => !v)}
316
+ className={`p-1 rounded text-xs transition-colors ${
317
+ wholeWord
318
+ ? "bg-[var(--color-accent)]/30 text-[var(--color-text-accent)] border border-[var(--color-accent)]/50"
319
+ : "text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)] border border-transparent"
320
+ }`}
321
+ title="Match Whole Word"
322
+ >
323
+ <WholeWord className="w-3.5 h-3.5" />
324
+ </button>
325
+ <button
326
+ onClick={() => setUseRegex((v) => !v)}
327
+ className={`p-1 rounded text-xs transition-colors ${
328
+ useRegex
329
+ ? "bg-[var(--color-accent)]/30 text-[var(--color-text-accent)] border border-[var(--color-accent)]/50"
330
+ : "text-[var(--color-text-placeholder)] hover:text-[var(--color-text-secondary)] border border-transparent"
331
+ }`}
332
+ title="Use Regular Expression"
333
+ >
334
+ <Regex className="w-3.5 h-3.5" />
335
+ </button>
336
+
337
+ <div className="flex-1" />
338
+
339
+ {isSearching ? (
340
+ <button
341
+ onClick={cancelSearch}
342
+ className="px-2 py-1 text-xs text-[var(--color-text-error)] hover:text-red-300 transition-colors flex items-center gap-1"
343
+ >
344
+ <Loader2 className="w-3 h-3 animate-spin" />
345
+ Cancel
346
+ </button>
347
+ ) : (
348
+ query.trim() && (
349
+ <button
350
+ onClick={performSearch}
351
+ className="px-2 py-1 text-xs bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] rounded transition-colors"
352
+ >
353
+ Search
354
+ </button>
355
+ )
356
+ )}
357
+ </div>
358
+ </div>
359
+
360
+ {/* Results header */}
361
+ {hasSearched && !isSearching && (
362
+ <div className="px-3 py-1.5 text-[11px] text-[var(--color-text-placeholder)] border-b border-[var(--color-bg-secondary)] flex-shrink-0">
363
+ {totalMatches > 0 ? (
364
+ <>
365
+ <span className="text-[var(--color-text-accent)] font-medium">{totalMatches}</span>{" "}
366
+ result{totalMatches !== 1 && "s"} in{" "}
367
+ <span className="text-[var(--color-text-accent)] font-medium">{results.length}</span>{" "}
368
+ file
369
+ {results.length !== 1 && "s"}
370
+ <span className="ml-2 text-gray-600">({filesSearched} files searched)</span>
371
+ </>
372
+ ) : (
373
+ "No results found"
374
+ )}
375
+ </div>
376
+ )}
377
+
378
+ {/* Loading state */}
379
+ {isSearching && (
380
+ <div className="flex items-center justify-center py-8 text-[var(--color-text-placeholder)] text-sm flex-shrink-0">
381
+ <Loader2 className="w-4 h-4 animate-spin mr-2" />
382
+ Searching...
383
+ </div>
384
+ )}
385
+
386
+ {/* Results list */}
387
+ <div className="flex-1 overflow-y-auto">
388
+ {!hasSearched && !isSearching ? (
389
+ <div className="flex flex-col items-center justify-center h-full text-gray-600 text-xs px-4">
390
+ <Search className="w-8 h-8 mb-2 text-gray-700" />
391
+ <p>Type a search term and press Enter</p>
392
+ <p className="mt-1 text-gray-700">{modKey}+Shift+F to focus search</p>
393
+ </div>
394
+ ) : !isSearching && totalMatches === 0 ? (
395
+ <div className="flex flex-col items-center justify-center h-full text-gray-600 text-xs px-4">
396
+ <FileText className="w-8 h-8 mb-2 text-gray-700" />
397
+ <p>No results for "{query}"</p>
398
+ <p className="mt-1 text-gray-700">Try different search terms</p>
399
+ </div>
400
+ ) : (
401
+ <div className="py-1">
402
+ {results.map((group, gi) => (
403
+ <div key={group.filePath}>
404
+ {/* File group header */}
405
+ <button
406
+ onClick={() => toggleGroup(gi)}
407
+ className="w-full flex items-center gap-1 px-2 py-1 text-xs hover:bg-[var(--color-bg-secondary)] transition-colors text-left"
408
+ >
409
+ {group.expanded ? (
410
+ <ChevronDown className="w-3 h-3 text-[var(--color-text-placeholder)] flex-shrink-0" />
411
+ ) : (
412
+ <ChevronRight className="w-3 h-3 text-[var(--color-text-placeholder)] flex-shrink-0" />
413
+ )}
414
+ <FileText className="w-3.5 h-3.5 text-[var(--color-text-placeholder)] flex-shrink-0" />
415
+ <span className="text-[var(--color-text-secondary)] truncate flex-1">
416
+ {group.relativePath}
417
+ </span>
418
+ <span className="text-[10px] text-gray-600 flex-shrink-0 ml-1">
419
+ {group.matches.length}
420
+ </span>
421
+ </button>
422
+
423
+ {/* Individual matches */}
424
+ {group.expanded && (
425
+ <div className="ml-4">
426
+ {group.matches.map((match, mi) => (
427
+ <button
428
+ key={mi}
429
+ onClick={() => handleOpenFile(match.file, match.line)}
430
+ className="w-full text-left px-2 py-0.5 text-[11px] hover:bg-[var(--color-bg-secondary)] transition-colors group flex items-start gap-2"
431
+ >
432
+ <span className="text-gray-600 w-6 text-right flex-shrink-0 select-none pt-px">
433
+ {match.line}
434
+ </span>
435
+ <span className="text-[var(--color-text-tertiary)] truncate font-mono leading-relaxed">
436
+ {match.text.slice(0, match.highlightStart)}
437
+ <span className="bg-[var(--color-accent)]/40 text-[var(--color-text-accent)]">
438
+ {match.text.slice(match.highlightStart, match.highlightEnd)}
439
+ </span>
440
+ {match.text.slice(match.highlightEnd)}
441
+ </span>
442
+ </button>
443
+ ))}
444
+ </div>
445
+ )}
446
+ </div>
447
+ ))}
448
+ </div>
449
+ )}
450
+ </div>
451
+ </div>
452
+ );
404
453
  }