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