@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.
- package/CHANGELOG.md +13 -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/{audio-preview-A6ScJemm.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-DQt8Bap3.js → conflict-editor-BYzf3LuW.js} +1 -1
- package/dist/web/assets/{database-viewer-C1k-aq-e.js → database-viewer-DjvnIn8p.js} +2 -2
- package/dist/web/assets/{diff-viewer-TowzH722.js → diff-viewer-CP2jcR5J.js} +1 -1
- package/dist/web/assets/{extension-webview-Cn1x5C5F.js → extension-webview-4xMREn_x.js} +1 -1
- package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
- package/dist/web/assets/{image-preview-MGnGKiYs.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/{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-DSINJjCx.js → markdown-renderer-Bj2B05Km.js} +1 -1
- package/dist/web/assets/{pdf-preview-BiI5Qihn.js → pdf-preview-CCyw5cuH.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-jjdgxhoi.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BwXJ-fGk.js → postgres-viewer-BrOiliEv.js} +2 -2
- package/dist/web/assets/{settings-store-BMZgnYTp.js → settings-store-BLLR7ed8.js} +2 -2
- package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
- package/dist/web/assets/{sql-query-editor-BSHd21AE.js → sql-query-editor-CVAnRFbi.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BPywcOES.js → sqlite-viewer-OEVq_-Po.js} +1 -1
- package/dist/web/assets/{terminal-tab-Civ2Yhce.js → terminal-tab-MjmJaQyA.js} +1 -1
- package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-CXs7t0_G.js → use-monaco-theme-BkZDwoVd.js} +1 -1
- package/dist/web/assets/{video-preview-Db5TkPSt.js → video-preview-B819qvlp.js} +1 -1
- package/dist/web/index.html +8 -8
- 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/providers/claude-agent-sdk.ts +23 -0
- 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/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/dist/web/assets/api-client-CwbMRXYl.js +0 -1
- package/dist/web/assets/chat-tab--Rc7WIJp.js +0 -12
- package/dist/web/assets/code-editor-DZSUYMBx.js +0 -8
- package/dist/web/assets/index-BrAupjGV.css +0 -2
- package/dist/web/assets/index-gxtJiPiW.js +0 -23
- package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
- package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
- package/dist/web/assets/settings-tab-USIB-LOd.js +0 -1
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 ? (
|
|
@@ -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
|
+
}
|