@hienlh/ppm 0.12.7 → 0.12.9

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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/web/assets/{ai-settings-section-BHdBBJtS.js → ai-settings-section-QE6nBNgN.js} +1 -1
  3. package/dist/web/assets/api-client-Dvzcc_EO.js +1 -0
  4. package/dist/web/assets/{api-settings-ByUGHhTB.js → api-settings-DAk7D-NP.js} +1 -1
  5. package/dist/web/assets/{audio-preview-A6ScJemm.js → audio-preview-DnQmf9fu.js} +1 -1
  6. package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
  7. package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
  8. package/dist/web/assets/{conflict-editor-DQt8Bap3.js → conflict-editor-BYzf3LuW.js} +1 -1
  9. package/dist/web/assets/{database-viewer-C1k-aq-e.js → database-viewer-DjvnIn8p.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-TowzH722.js → diff-viewer-CP2jcR5J.js} +1 -1
  11. package/dist/web/assets/{extension-webview-Cn1x5C5F.js → extension-webview-4xMREn_x.js} +1 -1
  12. package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
  13. package/dist/web/assets/{image-preview-MGnGKiYs.js → image-preview-CkS2PVdQ.js} +1 -1
  14. package/dist/web/assets/index-BTjuH4fn.css +2 -0
  15. package/dist/web/assets/index-FGlF8IWZ.js +23 -0
  16. package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
  17. package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
  18. package/dist/web/assets/{markdown-renderer-DSINJjCx.js → markdown-renderer-Bj2B05Km.js} +1 -1
  19. package/dist/web/assets/{pdf-preview-BiI5Qihn.js → pdf-preview-CCyw5cuH.js} +1 -1
  20. package/dist/web/assets/{port-forwarding-tab-jjdgxhoi.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
  21. package/dist/web/assets/{postgres-viewer-BwXJ-fGk.js → postgres-viewer-BrOiliEv.js} +2 -2
  22. package/dist/web/assets/{settings-store-BMZgnYTp.js → settings-store-BLLR7ed8.js} +2 -2
  23. package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
  24. package/dist/web/assets/{sql-query-editor-BSHd21AE.js → sql-query-editor-CVAnRFbi.js} +1 -1
  25. package/dist/web/assets/{sqlite-viewer-BPywcOES.js → sqlite-viewer-OEVq_-Po.js} +1 -1
  26. package/dist/web/assets/{terminal-tab-Civ2Yhce.js → terminal-tab-MjmJaQyA.js} +1 -1
  27. package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
  28. package/dist/web/assets/{use-monaco-theme-CXs7t0_G.js → use-monaco-theme-BkZDwoVd.js} +1 -1
  29. package/dist/web/assets/{video-preview-Db5TkPSt.js → video-preview-B819qvlp.js} +1 -1
  30. package/dist/web/index.html +8 -8
  31. package/dist/web/sw.js +1 -1
  32. package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
  33. package/docs/project-changelog.md +13 -1
  34. package/docs/system-architecture.md +79 -1
  35. package/package.json +1 -1
  36. package/src/providers/claude-agent-sdk.ts +23 -0
  37. package/src/server/index.ts +1 -1
  38. package/src/server/routes/files.ts +40 -2
  39. package/src/server/routes/projects.ts +53 -0
  40. package/src/server/routes/settings.ts +50 -1
  41. package/src/services/config.service.ts +41 -0
  42. package/src/services/db.service.ts +57 -1
  43. package/src/services/file-filter.service.ts +121 -0
  44. package/src/services/file-list-index.service.ts +170 -0
  45. package/src/services/file-watcher.service.ts +8 -4
  46. package/src/services/file.service.ts +55 -53
  47. package/src/services/upgrade.service.ts +2 -2
  48. package/src/types/chat.ts +2 -1
  49. package/src/types/project.ts +31 -0
  50. package/src/web/components/chat/file-picker.tsx +0 -13
  51. package/src/web/components/chat/message-input.tsx +11 -14
  52. package/src/web/components/chat/tool-cards.tsx +4 -2
  53. package/src/web/components/explorer/file-tree.tsx +91 -26
  54. package/src/web/components/layout/command-palette.tsx +26 -3
  55. package/src/web/components/settings/files-settings-section.tsx +230 -0
  56. package/src/web/components/settings/glob-list-editor.tsx +121 -0
  57. package/src/web/components/settings/settings-tab.tsx +5 -2
  58. package/src/web/lib/api-client.ts +2 -1
  59. package/src/web/lib/api-files-settings.ts +42 -0
  60. package/src/web/stores/file-store.ts +139 -14
  61. package/src/web/stores/file-tree-merge-helpers.ts +44 -0
  62. package/src/web/stores/jira-store.ts +1 -1
  63. package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
  64. package/dist/web/assets/chat-tab--Rc7WIJp.js +0 -12
  65. package/dist/web/assets/code-editor-DZSUYMBx.js +0 -8
  66. package/dist/web/assets/index-BrAupjGV.css +0 -2
  67. package/dist/web/assets/index-gxtJiPiW.js +0 -23
  68. package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
  69. package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
  70. package/dist/web/assets/settings-tab-USIB-LOd.js +0 -1
@@ -19,3 +19,34 @@ export interface FileNode {
19
19
  /** True if this path is matched by a .gitignore rule */
20
20
  ignored?: boolean;
21
21
  }
22
+
23
+ /** A flat file entry returned by /files/index */
24
+ export interface FileEntry {
25
+ path: string;
26
+ name: string;
27
+ type: "file" | "directory";
28
+ }
29
+
30
+ /** Entry returned by /files/list (single directory level) */
31
+ export interface FileDirEntry {
32
+ name: string;
33
+ type: "file" | "directory";
34
+ /** True if entry is excluded by gitignore (informational — still listed) */
35
+ isIgnored: boolean;
36
+ }
37
+
38
+ /** Per-project file filter override (stored in projects.settings JSON) */
39
+ export interface FileFilterConfig {
40
+ /** Additional glob patterns to exclude from tree/list */
41
+ filesExclude?: string[];
42
+ /** Additional glob patterns to exclude from index/search */
43
+ searchExclude?: string[];
44
+ /** Whether to use .gitignore rules (null = use global setting) */
45
+ useIgnoreFiles?: boolean;
46
+ }
47
+
48
+ /** Per-project settings stored in projects.settings JSON column */
49
+ export interface ProjectSettings {
50
+ files?: FileFilterConfig;
51
+ [key: string]: unknown;
52
+ }
@@ -10,19 +10,6 @@ interface FilePickerProps {
10
10
  visible: boolean;
11
11
  }
12
12
 
13
- /** Flatten a FileNode tree into a flat list of files and directories. */
14
- export function flattenFileTree(nodes: FileNode[]): FileNode[] {
15
- const result: FileNode[] = [];
16
- function walk(list: FileNode[]) {
17
- for (const node of list) {
18
- result.push(node);
19
- if (node.children) walk(node.children);
20
- }
21
- }
22
- walk(nodes);
23
- return result;
24
- }
25
-
26
13
  export function FilePicker({
27
14
  items,
28
15
  filter,
@@ -9,7 +9,7 @@ import { ModeSelector, getModeLabel, getModeIcon } from "./mode-selector";
9
9
  import { ProviderSelector } from "./provider-selector";
10
10
  import type { SlashItem } from "./slash-command-picker";
11
11
  import type { FileNode } from "../../../types/project";
12
- import { flattenFileTree } from "./file-picker";
12
+ import { useFileStore } from "@/stores/file-store";
13
13
 
14
14
  export interface ChatAttachment {
15
15
  id: string;
@@ -107,6 +107,10 @@ export const MessageInput = memo(function MessageInput({
107
107
  typeof CSS === "undefined" || !CSS.supports("field-sizing", "content"),
108
108
  );
109
109
 
110
+ // File index from store — replaces /files/tree?depth=5 fetch
111
+ const fileIndex = useFileStore((s) => s.fileIndex);
112
+ const indexStatus = useFileStore((s) => s.indexStatus);
113
+
110
114
  /** Write value to both textareas + ref + update hasText state */
111
115
  const writeTextareas = useCallback((newValue: string) => {
112
116
  valueRef.current = newValue;
@@ -204,25 +208,18 @@ export const MessageInput = memo(function MessageInput({
204
208
  return () => window.removeEventListener("ppm:slash-items-refresh", handler);
205
209
  }, [fetchSlashItems]);
206
210
 
207
- // Fetch file tree when projectName changes
211
+ // Sync file picker items from store index — no network call needed
208
212
  useEffect(() => {
209
213
  if (!projectName) {
210
214
  fileItemsRef.current = [];
211
215
  onFileItemsLoaded?.([]);
212
216
  return;
213
217
  }
214
- api
215
- .get<FileNode[]>(`${projectUrl(projectName)}/files/tree?depth=5`)
216
- .then((tree) => {
217
- const flat = flattenFileTree(tree);
218
- fileItemsRef.current = flat;
219
- onFileItemsLoaded?.(flat);
220
- })
221
- .catch(() => {
222
- fileItemsRef.current = [];
223
- onFileItemsLoaded?.([]);
224
- });
225
- }, [projectName]); // eslint-disable-line react-hooks/exhaustive-deps
218
+ // Convert FileEntry[] to FileNode[] — type field is now present on FileEntry
219
+ const nodes: FileNode[] = fileIndex.map((e) => ({ name: e.name, path: e.path, type: e.type }));
220
+ fileItemsRef.current = nodes;
221
+ onFileItemsLoaded?.(nodes);
222
+ }, [projectName, fileIndex, indexStatus]); // eslint-disable-line react-hooks/exhaustive-deps
226
223
 
227
224
  // Handle parent selecting a slash item
228
225
  useEffect(() => {
@@ -142,8 +142,10 @@ function ToolSummary({ name, input }: { name: string; input: Record<string, unkn
142
142
  case "MultiEdit":
143
143
  case "NotebookEdit":
144
144
  return <>{name} <span className="text-text-subtle">{basename(s(input.file_path))}</span></>;
145
- case "Bash":
146
- return <>{name} <span className="font-mono text-text-subtle">{truncate(s(input.command), 60)}</span></>;
145
+ case "Bash": {
146
+ const preview = input.description ? s(input.description) : s(input.command);
147
+ return <>{name} <span className={`text-text-subtle${input.description ? "" : " font-mono"}`}>{truncate(preview, 60)}</span></>;
148
+ }
147
149
  case "Glob":
148
150
  return <>{name} <span className="font-mono text-text-subtle">{s(input.pattern)}</span></>;
149
151
  case "Grep":
@@ -108,18 +108,28 @@ interface TreeNodeProps {
108
108
  }
109
109
 
110
110
  const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
111
- const { expandedPaths, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore(useShallow((s) => ({ expandedPaths: s.expandedPaths, toggleExpand: s.toggleExpand, selectedFiles: s.selectedFiles, toggleFileSelect: s.toggleFileSelect })));
111
+ const { expandedPaths, loadedPaths, inflight, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore(
112
+ useShallow((s) => ({
113
+ expandedPaths: s.expandedPaths,
114
+ loadedPaths: s.loadedPaths,
115
+ inflight: s.inflight,
116
+ toggleExpand: s.toggleExpand,
117
+ selectedFiles: s.selectedFiles,
118
+ toggleFileSelect: s.toggleFileSelect,
119
+ })),
120
+ );
112
121
  const openTab = useTabStore((s) => s.openTab);
113
122
  const isExpanded = expandedPaths.has(node.path);
114
123
  const isDir = node.type === "directory";
115
124
  const isSelected = selectedFiles.includes(node.path);
116
125
  const isIgnored = node.ignored === true;
126
+ const isLoadingChildren = isDir && isExpanded && !loadedPaths.has(node.path) && inflight.has(node.path);
117
127
  const [isDragOver, setIsDragOver] = useState(false);
118
128
  const dragCounter = useRef(0);
119
129
 
120
130
  function handleClick(e: React.MouseEvent) {
121
131
  if (isDir) {
122
- toggleExpand(node.path);
132
+ toggleExpand(projectName, node.path);
123
133
  return;
124
134
  }
125
135
  // Ctrl/Cmd+Click: toggle file selection for compare
@@ -211,7 +221,9 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
211
221
  style={{ paddingLeft: `${depth * 16 + 8}px` }}
212
222
  >
213
223
  {isDir ? (
214
- isExpanded ? (
224
+ isLoadingChildren ? (
225
+ <Loader2 className="size-3.5 shrink-0 text-text-subtle animate-spin" />
226
+ ) : isExpanded ? (
215
227
  <ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
216
228
  ) : (
217
229
  <ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
@@ -289,7 +301,29 @@ interface FileTreeProps {
289
301
  }
290
302
 
291
303
  export function FileTree({ onFileOpen }: FileTreeProps = {}) {
292
- const { tree, loading, error, fetchTree, reset, selectedFiles, clearSelection, setExpanded } = useFileStore(useShallow((s) => ({ tree: s.tree, loading: s.loading, error: s.error, fetchTree: s.fetchTree, reset: s.reset, selectedFiles: s.selectedFiles, clearSelection: s.clearSelection, setExpanded: s.setExpanded })));
304
+ const {
305
+ tree, loading, error,
306
+ loadRoot, loadIndex, loadChildren, invalidateIndex, invalidateFolder,
307
+ reset, selectedFiles, clearSelection, setExpanded,
308
+ // fetchTree kept for uploadFiles refresh
309
+ fetchTree,
310
+ } = useFileStore(
311
+ useShallow((s) => ({
312
+ tree: s.tree,
313
+ loading: s.loading,
314
+ error: s.error,
315
+ loadRoot: s.loadRoot,
316
+ loadIndex: s.loadIndex,
317
+ loadChildren: s.loadChildren,
318
+ invalidateIndex: s.invalidateIndex,
319
+ invalidateFolder: s.invalidateFolder,
320
+ reset: s.reset,
321
+ selectedFiles: s.selectedFiles,
322
+ clearSelection: s.clearSelection,
323
+ setExpanded: s.setExpanded,
324
+ fetchTree: s.fetchTree,
325
+ })),
326
+ );
293
327
  const activeProject = useProjectStore((s) => s.activeProject);
294
328
  const openTab = useTabStore((s) => s.openTab);
295
329
  const [actionState, setActionState] = useState<{
@@ -297,37 +331,59 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
297
331
  node: FileNode;
298
332
  } | null>(null);
299
333
 
300
- const loadTree = useCallback(() => {
301
- if (activeProject) {
302
- fetchTree(activeProject.name);
303
- }
304
- }, [activeProject, fetchTree]);
334
+ /** Full reload used by toolbar Refresh button and post-upload */
335
+ const reloadTree = useCallback(() => {
336
+ if (!activeProject) return;
337
+ reset();
338
+ loadRoot(activeProject.name);
339
+ loadIndex(activeProject.name);
340
+ }, [activeProject, reset, loadRoot, loadIndex]);
305
341
 
342
+ // On project switch: reset + load root + load index in parallel + auto-expand root (1 level)
306
343
  useEffect(() => {
307
- if (activeProject) {
308
- reset();
309
- loadTree();
310
- }
344
+ if (!activeProject) return;
345
+ reset();
346
+ const name = activeProject.name;
347
+
348
+ // Load root entries, then auto-expand the root node itself (path="")
349
+ loadRoot(name).then(() => {
350
+ // Auto-expand root — marks "" as expanded so root-level dirs show children on next expand
351
+ // Root entries are already visible; no deeper auto-expand per plan decision
352
+ useFileStore.getState().setExpanded("", true);
353
+ });
354
+ loadIndex(name);
311
355
  }, [activeProject?.name]); // eslint-disable-line react-hooks/exhaustive-deps
312
356
 
313
- // Auto-refresh file tree on window focus and real-time file changes via WebSocket
357
+ // Handle WS file:changed invalidate folder + index instead of full tree refetch
314
358
  useEffect(() => {
315
359
  if (!activeProject) return;
316
- const refresh = () => fetchTree(activeProject.name);
360
+ const projectName = activeProject.name;
317
361
  let debounceTimer: ReturnType<typeof setTimeout>;
318
- const debouncedRefresh = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(refresh, 300); };
362
+
319
363
  const handleFileChanged = (e: Event) => {
320
364
  const detail = (e as CustomEvent).detail;
321
- if (detail.projectName === activeProject.name) debouncedRefresh();
365
+ if (detail.projectName !== projectName) return;
366
+
367
+ clearTimeout(debounceTimer);
368
+ debounceTimer = setTimeout(() => {
369
+ const store = useFileStore.getState();
370
+ // Derive parent folder from changed file path
371
+ const changedPath: string = detail.path ?? "";
372
+ const parentPath = changedPath.includes("/")
373
+ ? changedPath.slice(0, changedPath.lastIndexOf("/"))
374
+ : "";
375
+ store.invalidateIndex();
376
+ store.loadIndex(projectName);
377
+ store.invalidateFolder(projectName, parentPath);
378
+ }, 300);
322
379
  };
323
- window.addEventListener("focus", refresh);
380
+
324
381
  window.addEventListener("file:changed", handleFileChanged);
325
382
  return () => {
326
383
  clearTimeout(debounceTimer);
327
- window.removeEventListener("focus", refresh);
328
384
  window.removeEventListener("file:changed", handleFileChanged);
329
385
  };
330
- }, [activeProject, fetchTree]);
386
+ }, [activeProject]);
331
387
 
332
388
  const uploadFiles = useCallback(async (targetDir: string, files: FileList) => {
333
389
  if (!activeProject) return;
@@ -347,12 +403,21 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
347
403
  const json = await res.json();
348
404
  console.error("Upload failed:", json.error);
349
405
  }
350
- loadTree();
406
+ // Invalidate the target folder so it refreshes
407
+ const store = useFileStore.getState();
408
+ const folderPath = targetDir;
409
+ const folderLoadedPaths = store.loadedPaths;
410
+ if (folderLoadedPaths.has(folderPath)) {
411
+ const lp = new Set(store.loadedPaths);
412
+ lp.delete(folderPath);
413
+ // Force reload by clearing and re-expanding
414
+ await store.invalidateFolder(activeProject.name, folderPath);
415
+ }
351
416
  if (targetDir) setExpanded(targetDir, true);
352
417
  } catch (e) {
353
418
  console.error("Upload error:", e);
354
419
  }
355
- }, [activeProject, loadTree, setExpanded]);
420
+ }, [activeProject, setExpanded]);
356
421
 
357
422
  const [isRootDragOver, setIsRootDragOver] = useState(false);
358
423
  const rootDragCounter = useRef(0);
@@ -436,7 +501,7 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
436
501
  return (
437
502
  <div className="p-3 text-xs text-error">
438
503
  {error}
439
- <button onClick={loadTree} className="block mt-1 text-primary underline">
504
+ <button onClick={reloadTree} className="block mt-1 text-primary underline">
440
505
  Retry
441
506
  </button>
442
507
  </div>
@@ -467,7 +532,7 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
467
532
  <FolderPlus className="size-3.5" />
468
533
  </button>
469
534
  <div className="flex-1" />
470
- <button onClick={loadTree} title="Refresh" className={toolbarBtnClass}>
535
+ <button onClick={reloadTree} title="Refresh" className={toolbarBtnClass}>
471
536
  <RefreshCw className="size-3.5" />
472
537
  </button>
473
538
  </div>
@@ -504,7 +569,7 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
504
569
  New Folder
505
570
  </ContextMenuItem>
506
571
  <ContextMenuSeparator />
507
- <ContextMenuItem onClick={loadTree}>
572
+ <ContextMenuItem onClick={reloadTree}>
508
573
  <RefreshCw className="size-3.5 mr-2" />
509
574
  Refresh
510
575
  </ContextMenuItem>
@@ -517,7 +582,7 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
517
582
  node={actionState.node}
518
583
  projectName={activeProject.name}
519
584
  onClose={() => setActionState(null)}
520
- onRefresh={loadTree}
585
+ onRefresh={reloadTree}
521
586
  />
522
587
  )}
523
588
  </div>
@@ -117,6 +117,9 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
117
117
 
118
118
  const openTab = useTabStore((s) => s.openTab);
119
119
  const activeProject = useProjectStore((s) => s.activeProject);
120
+ const fileIndex = useFileStore((s) => s.fileIndex);
121
+ const indexStatus = useFileStore((s) => s.indexStatus);
122
+ const loadIndex = useFileStore((s) => s.loadIndex);
120
123
  const fileTree = useFileStore((s) => s.tree);
121
124
  const setSidebarActiveTab = useSettingsStore((s) => s.setSidebarActiveTab);
122
125
  const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
@@ -223,11 +226,12 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
223
226
  return [...builtIn, ...extCmds];
224
227
  }, [activeProject, openTab, onClose, setSidebarActiveTab, sidebarCollapsed, toggleSidebar, getBinding, extContributions]);
225
228
 
226
- // File commands — derived from file store tree (project files)
229
+ // File commands — from index when ready, fallback to flattened tree
227
230
  const fileCommands = useMemo<CommandItem[]>(() => {
228
231
  const projectId = activeProject?.name ?? null;
229
232
  const meta = activeProject ? { projectName: activeProject.name } : undefined;
230
- const files = flattenFiles(fileTree);
233
+ // Filter index to files only — directories are in the index for palette "open folder" affordances but not for file-open commands
234
+ const files = indexStatus === "ready" ? fileIndex.filter((e) => e.type === "file") : flattenFiles(fileTree);
231
235
 
232
236
  return files.map((f) => ({
233
237
  id: `file:${f.path}`,
@@ -247,7 +251,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
247
251
  onClose();
248
252
  },
249
253
  }));
250
- }, [fileTree, activeProject, openTab, onClose]);
254
+ }, [indexStatus, fileIndex, fileTree, activeProject, openTab, onClose]);
251
255
 
252
256
  // Filesystem commands — from cached API results
253
257
  const fsCommands = useMemo<CommandItem[]>(() => {
@@ -427,6 +431,25 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
427
431
  </div>
428
432
  )}
429
433
 
434
+ {/* Index status hints — non-blocking, muted */}
435
+ {!pathMode && indexStatus === "loading" && (
436
+ <div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border/50">
437
+ <Loader2 className="size-3 animate-spin text-text-subtle shrink-0" />
438
+ <span className="text-[11px] text-text-subtle italic">Indexing project…</span>
439
+ </div>
440
+ )}
441
+ {!pathMode && indexStatus === "error" && (
442
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border/50">
443
+ <span className="text-[11px] text-text-subtle">Failed to build file index —</span>
444
+ <button
445
+ onClick={() => activeProject && loadIndex(activeProject.name)}
446
+ className="text-[11px] text-accent hover:underline"
447
+ >
448
+ retry
449
+ </button>
450
+ </div>
451
+ )}
452
+
430
453
  {/* Results */}
431
454
  <div ref={listRef} className="max-h-72 overflow-y-auto py-1">
432
455
  {filtered.length === 0 ? (
@@ -0,0 +1,230 @@
1
+ /**
2
+ * files-settings-section.tsx
3
+ * Settings section for file filter configuration: filesExclude, searchExclude, useIgnoreFiles.
4
+ * Supports global scope and per-project override (active project only — no dropdown).
5
+ */
6
+
7
+ import { useState, useEffect, useRef } from "react";
8
+ import { Switch } from "@/components/ui/switch";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Label } from "@/components/ui/label";
11
+ import { Separator } from "@/components/ui/separator";
12
+ import { useProjectStore } from "@/stores/project-store";
13
+ import { useFileStore } from "@/stores/file-store";
14
+ import {
15
+ getFilesSettings,
16
+ updateFilesSettings,
17
+ getProjectSettings,
18
+ updateProjectSettings,
19
+ type FileFilterSettings,
20
+ } from "@/lib/api-files-settings";
21
+ import { GlobListEditor } from "./glob-list-editor";
22
+
23
+ type Scope = "global" | "project";
24
+
25
+ /** Default values used when project override has no value for a field */
26
+ const DEFAULTS: FileFilterSettings = {
27
+ filesExclude: [],
28
+ searchExclude: [],
29
+ useIgnoreFiles: true,
30
+ };
31
+
32
+ export function FilesSettingsSection() {
33
+ const activeProject = useProjectStore((s) => s.activeProject);
34
+
35
+ const [scope, setScope] = useState<Scope>("global");
36
+ const [filesExclude, setFilesExclude] = useState<string[]>([]);
37
+ const [searchExclude, setSearchExclude] = useState<string[]>([]);
38
+ const [useIgnoreFiles, setUseIgnoreFiles] = useState(true);
39
+ const [saving, setSaving] = useState(false);
40
+ const [saved, setSaved] = useState(false);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [loading, setLoading] = useState(false);
43
+
44
+ // Abort controller for stale fetch cleanup
45
+ const abortRef = useRef<AbortController | null>(null);
46
+
47
+ // Load settings when scope or activeProject changes
48
+ useEffect(() => {
49
+ abortRef.current?.abort();
50
+ const ac = new AbortController();
51
+ abortRef.current = ac;
52
+
53
+ setLoading(true);
54
+ setError(null);
55
+
56
+ const loadSettings = async () => {
57
+ try {
58
+ if (scope === "global") {
59
+ const s = await getFilesSettings();
60
+ if (ac.signal.aborted) return;
61
+ setFilesExclude(s.filesExclude);
62
+ setSearchExclude(s.searchExclude);
63
+ setUseIgnoreFiles(s.useIgnoreFiles);
64
+ } else {
65
+ // Per-project: fetch project override; fill missing fields with defaults
66
+ if (!activeProject) return;
67
+ const ps = await getProjectSettings(activeProject.name);
68
+ if (ac.signal.aborted) return;
69
+ const f = ps.files ?? {};
70
+ setFilesExclude(f.filesExclude ?? DEFAULTS.filesExclude);
71
+ setSearchExclude(f.searchExclude ?? DEFAULTS.searchExclude);
72
+ setUseIgnoreFiles(f.useIgnoreFiles ?? DEFAULTS.useIgnoreFiles);
73
+ }
74
+ } catch (e) {
75
+ if (ac.signal.aborted) return;
76
+ setError((e as Error).message);
77
+ } finally {
78
+ if (!ac.signal.aborted) setLoading(false);
79
+ }
80
+ };
81
+
82
+ loadSettings();
83
+ return () => ac.abort();
84
+ }, [scope, activeProject?.name]);
85
+
86
+ const handleSave = async () => {
87
+ setSaving(true);
88
+ setSaved(false);
89
+ setError(null);
90
+ try {
91
+ const payload: FileFilterSettings = {
92
+ filesExclude: filesExclude.filter((p) => p.trim() !== ""),
93
+ searchExclude: searchExclude.filter((p) => p.trim() !== ""),
94
+ useIgnoreFiles,
95
+ };
96
+
97
+ if (scope === "global") {
98
+ await updateFilesSettings(payload);
99
+ } else {
100
+ if (!activeProject) throw new Error("No active project");
101
+ await updateProjectSettings(activeProject.name, { files: payload });
102
+ }
103
+
104
+ // Trigger server-side cache invalidation + frontend index reload
105
+ const store = useFileStore.getState();
106
+ store.invalidateIndex();
107
+ if (activeProject) {
108
+ store.loadIndex(activeProject.name);
109
+ }
110
+
111
+ setSaved(true);
112
+ setTimeout(() => setSaved(false), 2000);
113
+ } catch (e) {
114
+ setError((e as Error).message);
115
+ } finally {
116
+ setSaving(false);
117
+ }
118
+ };
119
+
120
+ const canSwitchToProject = !!activeProject;
121
+
122
+ return (
123
+ <div className="space-y-4">
124
+ <h3 className="text-xs font-medium text-muted-foreground">File Filters</h3>
125
+
126
+ {/* Scope toggle */}
127
+ <div className="flex gap-1">
128
+ <button
129
+ type="button"
130
+ onClick={() => setScope("global")}
131
+ className={`flex-1 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
132
+ scope === "global"
133
+ ? "bg-primary text-primary-foreground"
134
+ : "bg-muted text-muted-foreground hover:bg-accent"
135
+ }`}
136
+ >
137
+ Global
138
+ </button>
139
+ <button
140
+ type="button"
141
+ onClick={() => canSwitchToProject && setScope("project")}
142
+ disabled={!canSwitchToProject}
143
+ title={!canSwitchToProject ? "Open a project to edit per-project overrides" : undefined}
144
+ className={`flex-1 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
145
+ scope === "project"
146
+ ? "bg-primary text-primary-foreground"
147
+ : "bg-muted text-muted-foreground hover:bg-accent"
148
+ }`}
149
+ >
150
+ {activeProject ? activeProject.name : "Per-project"}
151
+ </button>
152
+ </div>
153
+
154
+ {scope === "project" && activeProject && (
155
+ <p className="text-[11px] text-muted-foreground -mt-2">
156
+ Overrides for <span className="font-medium">{activeProject.name}</span>.
157
+ Leave empty to use global settings.
158
+ </p>
159
+ )}
160
+
161
+ {loading ? (
162
+ <p className="text-xs text-muted-foreground">Loading...</p>
163
+ ) : (
164
+ <>
165
+ {/* Files Exclude */}
166
+ <div className="space-y-1.5">
167
+ <Label className="text-xs">Files to Exclude</Label>
168
+ <p className="text-[11px] text-muted-foreground">
169
+ Glob patterns hidden from the file tree and palette.
170
+ </p>
171
+ <GlobListEditor
172
+ value={filesExclude}
173
+ onChange={setFilesExclude}
174
+ placeholder="e.g. **/*.log or node_modules/**"
175
+ disabled={saving}
176
+ />
177
+ </div>
178
+
179
+ <Separator />
180
+
181
+ {/* Search Exclude */}
182
+ <div className="space-y-1.5">
183
+ <Label className="text-xs">Search to Exclude</Label>
184
+ <p className="text-[11px] text-muted-foreground">
185
+ Glob patterns excluded from file index / palette search.
186
+ </p>
187
+ <GlobListEditor
188
+ value={searchExclude}
189
+ onChange={setSearchExclude}
190
+ placeholder="e.g. dist/** or **/*.min.js"
191
+ disabled={saving}
192
+ />
193
+ </div>
194
+
195
+ <Separator />
196
+
197
+ {/* useIgnoreFiles toggle */}
198
+ <div className="flex items-center justify-between gap-2">
199
+ <div>
200
+ <Label className="text-xs">Use .gitignore rules</Label>
201
+ <p className="text-[11px] text-muted-foreground">
202
+ Respect .gitignore when filtering the file tree and index.
203
+ </p>
204
+ </div>
205
+ <Switch
206
+ checked={useIgnoreFiles}
207
+ onCheckedChange={setUseIgnoreFiles}
208
+ disabled={saving}
209
+ />
210
+ </div>
211
+
212
+ {/* Error */}
213
+ {error && (
214
+ <p className="text-[11px] text-destructive">{error}</p>
215
+ )}
216
+
217
+ {/* Save button */}
218
+ <Button
219
+ onClick={handleSave}
220
+ disabled={saving || loading}
221
+ size="sm"
222
+ className="h-8 text-xs w-full cursor-pointer"
223
+ >
224
+ {saving ? "Saving..." : saved ? "Saved" : "Save"}
225
+ </Button>
226
+ </>
227
+ )}
228
+ </div>
229
+ );
230
+ }