@hienlh/ppm 0.12.6 → 0.12.8

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 (90) hide show
  1. package/CHANGELOG.md +14 -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/architecture-PBZL5I3N-DvZbltvY.js +1 -0
  6. package/dist/web/assets/{audio-preview-BMmzgbUs.js → audio-preview-DnQmf9fu.js} +1 -1
  7. package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
  8. package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
  9. package/dist/web/assets/{conflict-editor-CBietP8L.js → conflict-editor-BYzf3LuW.js} +1 -1
  10. package/dist/web/assets/{database-viewer-CZgooyFp.js → database-viewer-DjvnIn8p.js} +2 -2
  11. package/dist/web/assets/{diff-viewer-BVYjlTcF.js → diff-viewer-CP2jcR5J.js} +1 -1
  12. package/dist/web/assets/{extension-webview-DyZOGDb1.js → extension-webview-4xMREn_x.js} +1 -1
  13. package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
  14. package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +1 -0
  15. package/dist/web/assets/github-dark-dimmed.min-BrpRStFV.css +1 -0
  16. package/dist/web/assets/github.min-D2BCvnWf.css +1 -0
  17. package/dist/web/assets/{image-preview-k8_kzoHe.js → image-preview-CkS2PVdQ.js} +1 -1
  18. package/dist/web/assets/index-BTjuH4fn.css +2 -0
  19. package/dist/web/assets/index-FGlF8IWZ.js +23 -0
  20. package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +1 -0
  21. package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
  22. package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
  23. package/dist/web/assets/{markdown-renderer-CJOPseDk.js → markdown-renderer-Bj2B05Km.js} +3 -3
  24. package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +1 -0
  25. package/dist/web/assets/{pdf-preview-GCIIaZVw.js → pdf-preview-CCyw5cuH.js} +1 -1
  26. package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +1 -0
  27. package/dist/web/assets/{port-forwarding-tab-DzLa02_D.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
  28. package/dist/web/assets/{postgres-viewer-JCT24Yqh.js → postgres-viewer-BrOiliEv.js} +2 -2
  29. package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +1 -0
  30. package/dist/web/assets/settings-store-BLLR7ed8.js +2 -0
  31. package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
  32. package/dist/web/assets/{sql-query-editor-JwymAmuK.js → sql-query-editor-CVAnRFbi.js} +1 -1
  33. package/dist/web/assets/{sqlite-viewer-nA_Biwex.js → sqlite-viewer-OEVq_-Po.js} +1 -1
  34. package/dist/web/assets/{terminal-tab-DvKxdDv4.js → terminal-tab-MjmJaQyA.js} +1 -1
  35. package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +1 -0
  36. package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
  37. package/dist/web/assets/{use-monaco-theme-o7Ip-BDL.js → use-monaco-theme-BkZDwoVd.js} +1 -1
  38. package/dist/web/assets/{vendor-mermaid-BlWh9BJO.js → vendor-mermaid-Dx86tuVP.js} +1 -1
  39. package/dist/web/assets/{video-preview-CAGgINCA.js → video-preview-B819qvlp.js} +1 -1
  40. package/dist/web/index.html +10 -10
  41. package/dist/web/sw.js +1 -1
  42. package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
  43. package/docs/project-changelog.md +13 -1
  44. package/docs/system-architecture.md +79 -1
  45. package/package.json +1 -1
  46. package/src/server/index.ts +1 -1
  47. package/src/server/routes/files.ts +40 -2
  48. package/src/server/routes/projects.ts +53 -0
  49. package/src/server/routes/settings.ts +50 -1
  50. package/src/services/config.service.ts +41 -0
  51. package/src/services/db.service.ts +57 -1
  52. package/src/services/file-filter.service.ts +121 -0
  53. package/src/services/file-list-index.service.ts +170 -0
  54. package/src/services/file-watcher.service.ts +8 -4
  55. package/src/services/file.service.ts +55 -53
  56. package/src/services/upgrade.service.ts +2 -2
  57. package/src/types/chat.ts +2 -1
  58. package/src/types/project.ts +31 -0
  59. package/src/web/components/chat/file-picker.tsx +0 -13
  60. package/src/web/components/chat/message-input.tsx +11 -14
  61. package/src/web/components/chat/tool-cards.tsx +4 -2
  62. package/src/web/components/explorer/file-tree.tsx +91 -26
  63. package/src/web/components/layout/command-palette.tsx +26 -3
  64. package/src/web/components/settings/files-settings-section.tsx +230 -0
  65. package/src/web/components/settings/glob-list-editor.tsx +121 -0
  66. package/src/web/components/settings/settings-tab.tsx +5 -2
  67. package/src/web/lib/api-client.ts +2 -1
  68. package/src/web/lib/api-files-settings.ts +42 -0
  69. package/src/web/main.tsx +1 -1
  70. package/src/web/stores/file-store.ts +139 -14
  71. package/src/web/stores/file-tree-merge-helpers.ts +44 -0
  72. package/src/web/stores/jira-store.ts +1 -1
  73. package/src/web/stores/settings-store.ts +20 -0
  74. package/src/web/styles/globals.css +2 -8
  75. package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
  76. package/dist/web/assets/architecture-PBZL5I3N-XX6_EZsC.js +0 -1
  77. package/dist/web/assets/chat-tab-NteLsEST.js +0 -12
  78. package/dist/web/assets/code-editor-Da9GXN5w.js +0 -8
  79. package/dist/web/assets/gitGraph-HDMCJU4V-BhjTKsbg.js +0 -1
  80. package/dist/web/assets/index-CDSox8V2.css +0 -2
  81. package/dist/web/assets/index-CXR1vYHY.js +0 -23
  82. package/dist/web/assets/info-3K5VOQVL-CzgVqYTx.js +0 -1
  83. package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
  84. package/dist/web/assets/packet-RMMSAZCW-C7agXrtd.js +0 -1
  85. package/dist/web/assets/pie-UPGHQEXC-BRZ7alnf.js +0 -1
  86. package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
  87. package/dist/web/assets/radar-KQ55EAFF-DSn_ekR5.js +0 -1
  88. package/dist/web/assets/settings-store-fDOEursg.js +0 -2
  89. package/dist/web/assets/settings-tab-bYmVV0Ww.js +0 -1
  90. package/dist/web/assets/treemap-KZPCXAKY-C8puYVyN.js +0 -1
@@ -9,11 +9,19 @@ import {
9
9
  rmSync,
10
10
  renameSync,
11
11
  } from "node:fs";
12
- import { resolve, relative, basename, dirname, join, normalize, sep } from "node:path";
12
+ import { resolve, relative, dirname, join, normalize, sep } from "node:path";
13
13
  import ignore, { type Ignore } from "ignore";
14
- import type { FileNode } from "../types/project.ts";
14
+ import type { FileNode, FileEntry, FileDirEntry } from "../types/project.ts";
15
+ import {
16
+ listDir as listDirImpl,
17
+ buildIndex as buildIndexImpl,
18
+ invalidateIndexCache,
19
+ clearIndexCache,
20
+ } from "./file-list-index.service.ts";
21
+
22
+ export { invalidateIndexCache, clearIndexCache };
15
23
 
16
- /** Directories/files excluded from tree listing */
24
+ /** Directories/files excluded from tree listing (legacy — kept for getTree back-compat) */
17
25
  const EXCLUDED_NAMES = new Set([".git", "node_modules"]);
18
26
 
19
27
  /** Load and compile gitignore rules from a project root */
@@ -103,13 +111,7 @@ class FileService {
103
111
  };
104
112
 
105
113
  if (entry.isDirectory()) {
106
- node.children = this.buildTree(
107
- rootPath,
108
- fullPath,
109
- currentDepth + 1,
110
- maxDepth,
111
- ig,
112
- );
114
+ node.children = this.buildTree(rootPath, fullPath, currentDepth + 1, maxDepth, ig);
113
115
  }
114
116
 
115
117
  nodes.push(node);
@@ -170,21 +172,14 @@ class FileService {
170
172
  }
171
173
 
172
174
  /** Read file content with encoding detection */
173
- readFile(
174
- projectPath: string,
175
- filePath: string,
176
- ): { content: string; encoding: string } {
175
+ readFile(projectPath: string, filePath: string): { content: string; encoding: string } {
177
176
  const absPath = this.resolveSafe(projectPath, filePath);
178
177
  this.blockSensitive(filePath);
179
178
 
180
- if (!existsSync(absPath)) {
181
- throw new NotFoundError(`File not found: ${filePath}`);
182
- }
179
+ if (!existsSync(absPath)) throw new NotFoundError(`File not found: ${filePath}`);
183
180
 
184
181
  const stat = statSync(absPath);
185
- if (stat.isDirectory()) {
186
- throw new ValidationError("Cannot read a directory");
187
- }
182
+ if (stat.isDirectory()) throw new ValidationError("Cannot read a directory");
188
183
 
189
184
  // Binary detection: check for null bytes in first chunk
190
185
  const buffer = readFileSync(absPath);
@@ -201,27 +196,18 @@ class FileService {
201
196
  const absPath = this.resolveSafe(projectPath, filePath);
202
197
  this.blockSensitive(filePath);
203
198
 
204
- // Ensure parent directory exists
205
199
  const dir = dirname(absPath);
206
- if (!existsSync(dir)) {
207
- mkdirSync(dir, { recursive: true });
208
- }
200
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
209
201
 
210
202
  writeFileSync(absPath, content, "utf-8");
211
203
  }
212
204
 
213
205
  /** Create a file or directory */
214
- createFile(
215
- projectPath: string,
216
- filePath: string,
217
- type: "file" | "directory",
218
- ): void {
206
+ createFile(projectPath: string, filePath: string, type: "file" | "directory"): void {
219
207
  const absPath = this.resolveSafe(projectPath, filePath);
220
208
  this.blockSensitive(filePath);
221
209
 
222
- if (existsSync(absPath)) {
223
- throw new ValidationError(`Already exists: ${filePath}`);
224
- }
210
+ if (existsSync(absPath)) throw new ValidationError(`Already exists: ${filePath}`);
225
211
 
226
212
  if (type === "directory") {
227
213
  mkdirSync(absPath, { recursive: true });
@@ -237,9 +223,7 @@ class FileService {
237
223
  const absPath = this.resolveSafe(projectPath, filePath);
238
224
  this.blockSensitive(filePath);
239
225
 
240
- if (!existsSync(absPath)) {
241
- throw new NotFoundError(`Not found: ${filePath}`);
242
- }
226
+ if (!existsSync(absPath)) throw new NotFoundError(`Not found: ${filePath}`);
243
227
 
244
228
  const stat = statSync(absPath);
245
229
  if (stat.isDirectory()) {
@@ -250,24 +234,15 @@ class FileService {
250
234
  }
251
235
 
252
236
  /** Rename a file or directory */
253
- renameFile(
254
- projectPath: string,
255
- oldPath: string,
256
- newPath: string,
257
- ): void {
237
+ renameFile(projectPath: string, oldPath: string, newPath: string): void {
258
238
  const absOld = this.resolveSafe(projectPath, oldPath);
259
239
  const absNew = this.resolveSafe(projectPath, newPath);
260
240
  this.blockSensitive(oldPath);
261
241
  this.blockSensitive(newPath);
262
242
 
263
- if (!existsSync(absOld)) {
264
- throw new NotFoundError(`Not found: ${oldPath}`);
265
- }
266
- if (existsSync(absNew)) {
267
- throw new ValidationError(`Already exists: ${newPath}`);
268
- }
243
+ if (!existsSync(absOld)) throw new NotFoundError(`Not found: ${oldPath}`);
244
+ if (existsSync(absNew)) throw new ValidationError(`Already exists: ${newPath}`);
269
245
 
270
- // Ensure parent dir of new path exists
271
246
  const dir = dirname(absNew);
272
247
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
273
248
 
@@ -275,15 +250,26 @@ class FileService {
275
250
  }
276
251
 
277
252
  /** Move a file or directory to a new location */
278
- moveFile(
279
- projectPath: string,
280
- source: string,
281
- destination: string,
282
- ): void {
283
- // Move is functionally the same as rename
253
+ moveFile(projectPath: string, source: string, destination: string): void {
284
254
  this.renameFile(projectPath, source, destination);
285
255
  }
286
256
 
257
+ /**
258
+ * List one directory level for lazy-load file tree (delegates to file-list-index.service).
259
+ * Applies filesExclude patterns; returns gitignore flag per entry.
260
+ */
261
+ listDir(projectPath: string, relPath: string): FileDirEntry[] {
262
+ return listDirImpl(projectPath, relPath);
263
+ }
264
+
265
+ /**
266
+ * Build flat file index for palette/search (delegates to file-list-index.service).
267
+ * Cached per project; invalidated on file change via invalidateIndexCache().
268
+ */
269
+ buildIndex(projectPath: string): FileEntry[] {
270
+ return buildIndexImpl(projectPath);
271
+ }
272
+
287
273
  /** Block access to sensitive paths (.git/) */
288
274
  private blockSensitive(filePath: string): void {
289
275
  const normalized = normalize(filePath);
@@ -319,3 +305,19 @@ export class ValidationError extends Error {
319
305
  }
320
306
 
321
307
  export const fileService = new FileService();
308
+
309
+ // Wire file watcher → index cache invalidation
310
+ // Dynamic import avoids circular dependency (file-watcher → chat.ts → file.service)
311
+ import("./file-watcher.service.ts").then(({ onFileChange }) => {
312
+ onFileChange((projectName) => {
313
+ try {
314
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
315
+ const { configService } = require("./config.service.ts");
316
+ const projects = configService.get("projects") as Array<{ name: string; path: string }>;
317
+ const project = projects.find((p: { name: string }) => p.name === projectName);
318
+ if (project) invalidateIndexCache(project.path);
319
+ } catch {
320
+ // Config not yet loaded or project not found — skip invalidation
321
+ }
322
+ });
323
+ }).catch(() => { /* file-watcher unavailable in test/CLI context */ });
@@ -23,8 +23,8 @@ export function getInstallMethod(): InstallMethod {
23
23
  /** Compare two semver strings (ignores pre-release tags). Returns -1 (a < b), 0 (equal), 1 (a > b) */
24
24
  export function compareSemver(a: string, b: string): -1 | 0 | 1 {
25
25
  // Strip pre-release suffix (e.g. "1.0.0-beta.1" → "1.0.0")
26
- const pa = a.split("-")[0].split(".").map(Number);
27
- const pb = b.split("-")[0].split(".").map(Number);
26
+ const pa = (a.split("-")[0] ?? "0").split(".").map(Number);
27
+ const pb = (b.split("-")[0] ?? "0").split(".").map(Number);
28
28
  for (let i = 0; i < 3; i++) {
29
29
  const va = pa[i] ?? 0;
30
30
  const vb = pb[i] ?? 0;
package/src/types/chat.ts CHANGED
@@ -132,7 +132,8 @@ export type ChatEvent =
132
132
  | { type: "system"; subtype: string }
133
133
  | { type: "team_detected"; teamName: string }
134
134
  | { type: "team_updated"; teamName: string; team: unknown }
135
- | { type: "team_inbox"; teamName: string; agent: string; messages: unknown[] };
135
+ | { type: "team_inbox"; teamName: string; agent: string; messages: unknown[] }
136
+ | { type: "session_migrated"; oldSessionId: string; newSessionId: string };
136
137
 
137
138
  export type ToolApprovalHandler = (
138
139
  tool: string,
@@ -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 ? (