@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.
- package/CHANGELOG.md +14 -0
- package/dist/web/assets/{ai-settings-section-BHdBBJtS.js → ai-settings-section-QE6nBNgN.js} +1 -1
- package/dist/web/assets/api-client-Dvzcc_EO.js +1 -0
- package/dist/web/assets/{api-settings-ByUGHhTB.js → api-settings-DAk7D-NP.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +1 -0
- package/dist/web/assets/{audio-preview-BMmzgbUs.js → audio-preview-DnQmf9fu.js} +1 -1
- package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
- package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
- package/dist/web/assets/{conflict-editor-CBietP8L.js → conflict-editor-BYzf3LuW.js} +1 -1
- package/dist/web/assets/{database-viewer-CZgooyFp.js → database-viewer-DjvnIn8p.js} +2 -2
- package/dist/web/assets/{diff-viewer-BVYjlTcF.js → diff-viewer-CP2jcR5J.js} +1 -1
- package/dist/web/assets/{extension-webview-DyZOGDb1.js → extension-webview-4xMREn_x.js} +1 -1
- package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +1 -0
- package/dist/web/assets/github-dark-dimmed.min-BrpRStFV.css +1 -0
- package/dist/web/assets/github.min-D2BCvnWf.css +1 -0
- package/dist/web/assets/{image-preview-k8_kzoHe.js → image-preview-CkS2PVdQ.js} +1 -1
- package/dist/web/assets/index-BTjuH4fn.css +2 -0
- package/dist/web/assets/index-FGlF8IWZ.js +23 -0
- package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +1 -0
- package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
- package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
- package/dist/web/assets/{markdown-renderer-CJOPseDk.js → markdown-renderer-Bj2B05Km.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +1 -0
- package/dist/web/assets/{pdf-preview-GCIIaZVw.js → pdf-preview-CCyw5cuH.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-DzLa02_D.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
- package/dist/web/assets/{postgres-viewer-JCT24Yqh.js → postgres-viewer-BrOiliEv.js} +2 -2
- package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +1 -0
- package/dist/web/assets/settings-store-BLLR7ed8.js +2 -0
- package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
- package/dist/web/assets/{sql-query-editor-JwymAmuK.js → sql-query-editor-CVAnRFbi.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-nA_Biwex.js → sqlite-viewer-OEVq_-Po.js} +1 -1
- package/dist/web/assets/{terminal-tab-DvKxdDv4.js → terminal-tab-MjmJaQyA.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +1 -0
- package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-o7Ip-BDL.js → use-monaco-theme-BkZDwoVd.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-BlWh9BJO.js → vendor-mermaid-Dx86tuVP.js} +1 -1
- package/dist/web/assets/{video-preview-CAGgINCA.js → video-preview-B819qvlp.js} +1 -1
- package/dist/web/index.html +10 -10
- package/dist/web/sw.js +1 -1
- package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
- package/docs/project-changelog.md +13 -1
- package/docs/system-architecture.md +79 -1
- package/package.json +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/routes/files.ts +40 -2
- package/src/server/routes/projects.ts +53 -0
- package/src/server/routes/settings.ts +50 -1
- package/src/services/config.service.ts +41 -0
- package/src/services/db.service.ts +57 -1
- package/src/services/file-filter.service.ts +121 -0
- package/src/services/file-list-index.service.ts +170 -0
- package/src/services/file-watcher.service.ts +8 -4
- package/src/services/file.service.ts +55 -53
- package/src/services/upgrade.service.ts +2 -2
- package/src/types/chat.ts +2 -1
- package/src/types/project.ts +31 -0
- package/src/web/components/chat/file-picker.tsx +0 -13
- package/src/web/components/chat/message-input.tsx +11 -14
- package/src/web/components/chat/tool-cards.tsx +4 -2
- package/src/web/components/explorer/file-tree.tsx +91 -26
- package/src/web/components/layout/command-palette.tsx +26 -3
- package/src/web/components/settings/files-settings-section.tsx +230 -0
- package/src/web/components/settings/glob-list-editor.tsx +121 -0
- package/src/web/components/settings/settings-tab.tsx +5 -2
- package/src/web/lib/api-client.ts +2 -1
- package/src/web/lib/api-files-settings.ts +42 -0
- package/src/web/main.tsx +1 -1
- package/src/web/stores/file-store.ts +139 -14
- package/src/web/stores/file-tree-merge-helpers.ts +44 -0
- package/src/web/stores/jira-store.ts +1 -1
- package/src/web/stores/settings-store.ts +20 -0
- package/src/web/styles/globals.css +2 -8
- package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-XX6_EZsC.js +0 -1
- package/dist/web/assets/chat-tab-NteLsEST.js +0 -12
- package/dist/web/assets/code-editor-Da9GXN5w.js +0 -8
- package/dist/web/assets/gitGraph-HDMCJU4V-BhjTKsbg.js +0 -1
- package/dist/web/assets/index-CDSox8V2.css +0 -2
- package/dist/web/assets/index-CXR1vYHY.js +0 -23
- package/dist/web/assets/info-3K5VOQVL-CzgVqYTx.js +0 -1
- package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-C7agXrtd.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BRZ7alnf.js +0 -1
- package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-DSn_ekR5.js +0 -1
- package/dist/web/assets/settings-store-fDOEursg.js +0 -2
- package/dist/web/assets/settings-tab-bYmVV0Ww.js +0 -1
- 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,
|
|
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
|
-
|
|
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,
|
package/src/types/project.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
//
|
|
357
|
+
// Handle WS file:changed → invalidate folder + index instead of full tree refetch
|
|
314
358
|
useEffect(() => {
|
|
315
359
|
if (!activeProject) return;
|
|
316
|
-
const
|
|
360
|
+
const projectName = activeProject.name;
|
|
317
361
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
318
|
-
|
|
362
|
+
|
|
319
363
|
const handleFileChanged = (e: Event) => {
|
|
320
364
|
const detail = (e as CustomEvent).detail;
|
|
321
|
-
if (detail.projectName
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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 —
|
|
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
|
-
|
|
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 ? (
|