@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { configService } from "../../services/config.service.ts";
|
|
2
|
+
import { configService, FILE_CONFIG_KEYS } from "../../services/config.service.ts";
|
|
3
3
|
import { getConfigValue, setConfigValue, listPairedChats, getPairingByCode, approvePairing, revokePairing, getPPMBotMemories, getDb } from "../../services/db.service.ts";
|
|
4
4
|
import {
|
|
5
5
|
validateAIProviderConfig,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../../types/config.ts";
|
|
14
14
|
import { ok, err } from "../../types/api.ts";
|
|
15
15
|
import { proxyService } from "../../services/proxy.service.ts";
|
|
16
|
+
import { clearIndexCache } from "../../services/file-list-index.service.ts";
|
|
16
17
|
import { providerRegistry } from "../../providers/registry.ts";
|
|
17
18
|
|
|
18
19
|
export const settingsRoutes = new Hono();
|
|
@@ -405,6 +406,54 @@ settingsRoutes.delete("/clawbot/memories/:id", (c) => {
|
|
|
405
406
|
}
|
|
406
407
|
});
|
|
407
408
|
|
|
409
|
+
// ── File Filters ──────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/** GET /settings/files — return global file filter config */
|
|
412
|
+
settingsRoutes.get("/files", (c) => {
|
|
413
|
+
return c.json(ok({
|
|
414
|
+
filesExclude: configService.getFilesExclude(),
|
|
415
|
+
searchExclude: configService.getSearchExclude(),
|
|
416
|
+
useIgnoreFiles: configService.getUseIgnoreFiles(),
|
|
417
|
+
}));
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
/** PATCH /settings/files — partial update to global file filter config */
|
|
421
|
+
settingsRoutes.patch("/files", async (c) => {
|
|
422
|
+
try {
|
|
423
|
+
const body = await c.req.json<{
|
|
424
|
+
filesExclude?: string[];
|
|
425
|
+
searchExclude?: string[];
|
|
426
|
+
useIgnoreFiles?: boolean;
|
|
427
|
+
}>();
|
|
428
|
+
|
|
429
|
+
if (body.filesExclude !== undefined) {
|
|
430
|
+
if (!Array.isArray(body.filesExclude)) return c.json(err("filesExclude must be an array"), 400);
|
|
431
|
+
const patterns = body.filesExclude.filter((p) => typeof p === "string").slice(0, 200);
|
|
432
|
+
setConfigValue(FILE_CONFIG_KEYS.filesExclude, JSON.stringify(patterns));
|
|
433
|
+
}
|
|
434
|
+
if (body.searchExclude !== undefined) {
|
|
435
|
+
if (!Array.isArray(body.searchExclude)) return c.json(err("searchExclude must be an array"), 400);
|
|
436
|
+
const patterns = body.searchExclude.filter((p) => typeof p === "string").slice(0, 200);
|
|
437
|
+
setConfigValue(FILE_CONFIG_KEYS.searchExclude, JSON.stringify(patterns));
|
|
438
|
+
}
|
|
439
|
+
if (body.useIgnoreFiles !== undefined) {
|
|
440
|
+
if (typeof body.useIgnoreFiles !== "boolean") return c.json(err("useIgnoreFiles must be a boolean"), 400);
|
|
441
|
+
setConfigValue(FILE_CONFIG_KEYS.useIgnoreFiles, JSON.stringify(body.useIgnoreFiles));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Invalidate all project index caches — global filter changes affect every project
|
|
445
|
+
clearIndexCache();
|
|
446
|
+
|
|
447
|
+
return c.json(ok({
|
|
448
|
+
filesExclude: configService.getFilesExclude(),
|
|
449
|
+
searchExclude: configService.getSearchExclude(),
|
|
450
|
+
useIgnoreFiles: configService.getUseIgnoreFiles(),
|
|
451
|
+
}));
|
|
452
|
+
} catch (e) {
|
|
453
|
+
return c.json(err((e as Error).message), 400);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
408
457
|
/** GET /settings/clawbot/tasks — list recent delegated tasks */
|
|
409
458
|
settingsRoutes.get("/clawbot/tasks", (c) => {
|
|
410
459
|
const limit = Number(c.req.query("limit")) || 20;
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
deleteProject as dbDeleteProject,
|
|
13
13
|
getDb,
|
|
14
14
|
getDbFilePath,
|
|
15
|
+
getProjectSettingsJson,
|
|
16
|
+
patchProjectSettingsJson,
|
|
15
17
|
} from "./db.service.ts";
|
|
16
18
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
17
19
|
|
|
@@ -20,6 +22,13 @@ const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
|
|
|
20
22
|
"device_name", "port", "host", "theme", "auth", "ai", "push", "telegram", "clawbot",
|
|
21
23
|
];
|
|
22
24
|
|
|
25
|
+
/** File filter config keys stored in the config table */
|
|
26
|
+
export const FILE_CONFIG_KEYS = {
|
|
27
|
+
filesExclude: "files.exclude",
|
|
28
|
+
searchExclude: "files.searchExclude",
|
|
29
|
+
useIgnoreFiles: "files.useIgnoreFiles",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
23
32
|
class ConfigService {
|
|
24
33
|
private config: PpmConfig = structuredClone(DEFAULT_CONFIG);
|
|
25
34
|
|
|
@@ -98,6 +107,38 @@ class ConfigService {
|
|
|
98
107
|
return getDbFilePath();
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/** Get global files.exclude patterns (falls back to empty array if not set) */
|
|
111
|
+
getFilesExclude(): string[] {
|
|
112
|
+
const raw = getConfigValue(FILE_CONFIG_KEYS.filesExclude);
|
|
113
|
+
if (!raw) return [];
|
|
114
|
+
try { return JSON.parse(raw) as string[]; } catch { return []; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Get global files.searchExclude patterns */
|
|
118
|
+
getSearchExclude(): string[] {
|
|
119
|
+
const raw = getConfigValue(FILE_CONFIG_KEYS.searchExclude);
|
|
120
|
+
if (!raw) return [];
|
|
121
|
+
try { return JSON.parse(raw) as string[]; } catch { return []; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Get global files.useIgnoreFiles flag (default true) */
|
|
125
|
+
getUseIgnoreFiles(): boolean {
|
|
126
|
+
const raw = getConfigValue(FILE_CONFIG_KEYS.useIgnoreFiles);
|
|
127
|
+
if (raw === null) return true; // default on
|
|
128
|
+
try { return JSON.parse(raw) as boolean; } catch { return true; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Get per-project settings for a given project path */
|
|
132
|
+
getProjectSettings(projectPath: string): import("../types/project.ts").ProjectSettings {
|
|
133
|
+
const json = getProjectSettingsJson(projectPath);
|
|
134
|
+
try { return JSON.parse(json) as import("../types/project.ts").ProjectSettings; } catch { return {}; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Merge-patch per-project settings */
|
|
138
|
+
setProjectSettings(projectPath: string, patch: import("../types/project.ts").ProjectSettings): void {
|
|
139
|
+
patchProjectSettingsJson(projectPath, JSON.stringify(patch));
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
/** No-op — kept for backward compatibility (init command) */
|
|
102
143
|
setConfigPath(_p: string): void {}
|
|
103
144
|
|
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
|
-
const CURRENT_SCHEMA_VERSION =
|
|
6
|
+
const CURRENT_SCHEMA_VERSION = 21;
|
|
7
7
|
|
|
8
8
|
let db: Database | null = null;
|
|
9
9
|
let dbProfile: string | null = null;
|
|
@@ -589,6 +589,16 @@ function runMigrations(database: Database): void {
|
|
|
589
589
|
}
|
|
590
590
|
database.exec("PRAGMA user_version = 20");
|
|
591
591
|
}
|
|
592
|
+
|
|
593
|
+
if (current < 21) {
|
|
594
|
+
// Add per-project settings JSON column (idempotent: check column existence first)
|
|
595
|
+
const cols = database.query("PRAGMA table_info(projects)").all() as { name: string }[];
|
|
596
|
+
const hasSettings = cols.some((c) => c.name === "settings");
|
|
597
|
+
if (!hasSettings) {
|
|
598
|
+
database.exec(`ALTER TABLE projects ADD COLUMN settings TEXT DEFAULT '{}'`);
|
|
599
|
+
}
|
|
600
|
+
database.exec("PRAGMA user_version = 21");
|
|
601
|
+
}
|
|
592
602
|
}
|
|
593
603
|
|
|
594
604
|
// ---------------------------------------------------------------------------
|
|
@@ -668,6 +678,52 @@ export function deleteProject(nameOrPath: string): void {
|
|
|
668
678
|
getDb().query("DELETE FROM projects WHERE name = ? OR path = ?").run(nameOrPath, nameOrPath);
|
|
669
679
|
}
|
|
670
680
|
|
|
681
|
+
/** Get per-project settings JSON string (returns '{}' if missing or null) */
|
|
682
|
+
export function getProjectSettingsJson(projectPath: string): string {
|
|
683
|
+
const row = getDb().query("SELECT settings FROM projects WHERE path = ?").get(projectPath) as { settings: string | null } | null;
|
|
684
|
+
return row?.settings ?? "{}";
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Patch per-project settings JSON (deep merge — plain-object values are merged one level deep) */
|
|
688
|
+
export function patchProjectSettingsJson(projectPath: string, patch: string): void {
|
|
689
|
+
const existing = getProjectSettingsJson(projectPath);
|
|
690
|
+
|
|
691
|
+
let existingObj: Record<string, unknown>;
|
|
692
|
+
let patchObj: Record<string, unknown>;
|
|
693
|
+
try { existingObj = (JSON.parse(existing) ?? {}) as Record<string, unknown>; }
|
|
694
|
+
catch { existingObj = {}; }
|
|
695
|
+
try { patchObj = JSON.parse(patch) as Record<string, unknown>; }
|
|
696
|
+
catch { throw new Error("Invalid patch JSON"); }
|
|
697
|
+
|
|
698
|
+
if (typeof patchObj !== "object" || patchObj === null || Array.isArray(patchObj)) {
|
|
699
|
+
throw new Error("Patch must be a plain object");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// One-level deep merge: if both sides have a plain-object value, shallow-merge; else replace
|
|
703
|
+
const merged: Record<string, unknown> = { ...existingObj };
|
|
704
|
+
for (const key of Object.keys(patchObj)) {
|
|
705
|
+
const pv = patchObj[key];
|
|
706
|
+
const ev = existingObj[key];
|
|
707
|
+
if (isPlainObject(pv) && isPlainObject(ev)) {
|
|
708
|
+
merged[key] = { ...(ev as Record<string, unknown>), ...(pv as Record<string, unknown>) };
|
|
709
|
+
} else {
|
|
710
|
+
merged[key] = pv;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const result = getDb()
|
|
715
|
+
.query("UPDATE projects SET settings = ? WHERE path = ?")
|
|
716
|
+
.run(JSON.stringify(merged), projectPath);
|
|
717
|
+
|
|
718
|
+
if ((result as { changes: number }).changes === 0) {
|
|
719
|
+
throw new Error(`Project not found: ${projectPath}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function isPlainObject(v: unknown): boolean {
|
|
724
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
725
|
+
}
|
|
726
|
+
|
|
671
727
|
export function updateProject(currentName: string, newName: string, newPath: string, color?: string | null): void {
|
|
672
728
|
getDb().query(
|
|
673
729
|
"UPDATE projects SET name = ?, path = ?, color = ? WHERE name = ?",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-filter.service.ts
|
|
3
|
+
* Resolves VS Code-style file exclude patterns for lazy-load file tree.
|
|
4
|
+
* Precedence: hardcoded defaults < global config < per-project override (last wins).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FileFilterConfig } from "../types/project.ts";
|
|
8
|
+
import { configService } from "./config.service.ts";
|
|
9
|
+
|
|
10
|
+
/** Patterns always excluded from tree listing (cannot be overridden by config) */
|
|
11
|
+
export const HARDCODED_FILES_EXCLUDE = [
|
|
12
|
+
"**/.git",
|
|
13
|
+
"**/.DS_Store",
|
|
14
|
+
"**/Thumbs.db",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Patterns always excluded from index/search */
|
|
18
|
+
export const HARDCODED_SEARCH_EXCLUDE = [
|
|
19
|
+
"**/node_modules",
|
|
20
|
+
"**/dist",
|
|
21
|
+
"**/build",
|
|
22
|
+
"**/.next",
|
|
23
|
+
"**/target",
|
|
24
|
+
"**/.venv",
|
|
25
|
+
"**/.cache",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export interface ResolvedFilter {
|
|
29
|
+
/** Combined filesExclude: hardcoded + global + project (deduped) */
|
|
30
|
+
filesExclude: string[];
|
|
31
|
+
/** Combined searchExclude: hardcoded + global + project (deduped) */
|
|
32
|
+
searchExclude: string[];
|
|
33
|
+
/** Whether to apply gitignore rules */
|
|
34
|
+
useIgnoreFiles: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve final filter config for a project path.
|
|
39
|
+
* Merges: hardcoded defaults ∪ global config ∪ per-project override.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveFilter(projectPath: string): ResolvedFilter {
|
|
42
|
+
const globalFilesExclude = configService.getFilesExclude();
|
|
43
|
+
const globalSearchExclude = configService.getSearchExclude();
|
|
44
|
+
const globalUseIgnoreFiles = configService.getUseIgnoreFiles();
|
|
45
|
+
|
|
46
|
+
const projectSettings = configService.getProjectSettings(projectPath);
|
|
47
|
+
const projectFilter: FileFilterConfig = projectSettings.files ?? {};
|
|
48
|
+
|
|
49
|
+
// Merge arrays (dedup)
|
|
50
|
+
const filesExclude = dedup([
|
|
51
|
+
...HARDCODED_FILES_EXCLUDE,
|
|
52
|
+
...globalFilesExclude,
|
|
53
|
+
...(projectFilter.filesExclude ?? []),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const searchExclude = dedup([
|
|
57
|
+
...HARDCODED_SEARCH_EXCLUDE,
|
|
58
|
+
...globalSearchExclude,
|
|
59
|
+
...(projectFilter.searchExclude ?? []),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Per-project useIgnoreFiles overrides global if set
|
|
63
|
+
const useIgnoreFiles = projectFilter.useIgnoreFiles !== undefined
|
|
64
|
+
? projectFilter.useIgnoreFiles
|
|
65
|
+
: globalUseIgnoreFiles;
|
|
66
|
+
|
|
67
|
+
return { filesExclude, searchExclude, useIgnoreFiles };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function dedup(arr: string[]): string[] {
|
|
71
|
+
return [...new Set(arr)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a relative path matches any of the given glob patterns.
|
|
76
|
+
* Uses simple pattern-to-regex conversion (no external lib needed).
|
|
77
|
+
* Patterns follow VS Code glob semantics: ** crosses dirs, * stays in one segment.
|
|
78
|
+
*/
|
|
79
|
+
export function matchesGlob(relPath: string, patterns: string[]): boolean {
|
|
80
|
+
// Normalize path separators to forward slash
|
|
81
|
+
const normalized = relPath.split("\\").join("/");
|
|
82
|
+
return patterns.some((pattern) => matchSingleGlob(normalized, pattern));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function matchSingleGlob(relPath: string, pattern: string): boolean {
|
|
86
|
+
// Strip leading **/ for simpler matching — handled by regex
|
|
87
|
+
const re = globPatternToRegex(pattern);
|
|
88
|
+
return re.test(relPath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert a VS Code-style glob pattern to a RegExp.
|
|
93
|
+
* Cached per unique pattern string for performance.
|
|
94
|
+
*/
|
|
95
|
+
const regexCache = new Map<string, RegExp>();
|
|
96
|
+
|
|
97
|
+
function globPatternToRegex(pattern: string): RegExp {
|
|
98
|
+
const cached = regexCache.get(pattern);
|
|
99
|
+
if (cached) return cached;
|
|
100
|
+
|
|
101
|
+
let p = pattern;
|
|
102
|
+
// Normalize path separators
|
|
103
|
+
p = p.split("\\").join("/");
|
|
104
|
+
// Strip leading ./
|
|
105
|
+
if (p.startsWith("./")) p = p.slice(2);
|
|
106
|
+
|
|
107
|
+
const escaped = p
|
|
108
|
+
.replace(/[.+^${}()|[\]]/g, "\\$&") // escape regex special chars (not * ?)
|
|
109
|
+
.replace(/\*\*/g, "\x00") // temp: ** placeholder
|
|
110
|
+
.replace(/\*/g, "[^/]*") // * = within one path segment
|
|
111
|
+
.replace(/\x00/g, ".*") // ** = any path
|
|
112
|
+
.replace(/\?/g, "[^/]"); // ? = single non-slash char
|
|
113
|
+
|
|
114
|
+
// Pattern with no slash (e.g. *.log) → match at any depth
|
|
115
|
+
const re = p.includes("/")
|
|
116
|
+
? new RegExp(`^${escaped}(/|$)`)
|
|
117
|
+
: new RegExp(`(^|/)${escaped}(/|$)`);
|
|
118
|
+
|
|
119
|
+
regexCache.set(pattern, re);
|
|
120
|
+
return re;
|
|
121
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-list-index.service.ts
|
|
3
|
+
* Lazy-load file tree listing and flat index building for palette/search.
|
|
4
|
+
* Implements listDir() (1-level) and buildIndex() (recursive) with filter support.
|
|
5
|
+
* Index results are cached per project path and invalidated on file changes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve, relative, join } from "node:path";
|
|
10
|
+
import ignore, { type Ignore } from "ignore";
|
|
11
|
+
import type { FileEntry, FileDirEntry } from "../types/project.ts";
|
|
12
|
+
import { matchesGlob, resolveFilter } from "./file-filter.service.ts";
|
|
13
|
+
import { SecurityError, NotFoundError } from "./file.service.ts";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Index cache keyed by absolute project path
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const indexCache = new Map<string, FileEntry[]>();
|
|
20
|
+
|
|
21
|
+
/** Invalidate cached flat index for a project (called on file change events) */
|
|
22
|
+
export function invalidateIndexCache(projectPath: string): void {
|
|
23
|
+
indexCache.delete(projectPath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Clear all cached indexes (e.g. for tests) */
|
|
27
|
+
export function clearIndexCache(): void {
|
|
28
|
+
indexCache.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Gitignore loader (shared utility)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function loadGitignore(projectPath: string): Ignore {
|
|
36
|
+
const ig = ignore();
|
|
37
|
+
const gitignorePath = join(projectPath, ".gitignore");
|
|
38
|
+
if (existsSync(gitignorePath)) {
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
41
|
+
ig.add(content);
|
|
42
|
+
} catch { /* unreadable — skip */ }
|
|
43
|
+
}
|
|
44
|
+
return ig;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Path traversal guard
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function assertWithinProject(relPath: string, projectPath: string): void {
|
|
52
|
+
const abs = resolve(projectPath, relPath);
|
|
53
|
+
if (!abs.startsWith(projectPath + "/") && abs !== projectPath) {
|
|
54
|
+
throw new SecurityError("Path traversal not allowed");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// listDir — single directory level
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* List one directory level for lazy-load file tree.
|
|
64
|
+
* Applies filesExclude patterns from resolved filter.
|
|
65
|
+
* Marks entries as isIgnored based on .gitignore (informational — still listed).
|
|
66
|
+
*/
|
|
67
|
+
export function listDir(projectPath: string, relPath: string): FileDirEntry[] {
|
|
68
|
+
if (relPath) assertWithinProject(relPath, projectPath);
|
|
69
|
+
|
|
70
|
+
const absDir = relPath ? resolve(projectPath, relPath) : projectPath;
|
|
71
|
+
if (!existsSync(absDir)) throw new NotFoundError(`Directory not found: ${relPath || "/"}`);
|
|
72
|
+
|
|
73
|
+
const filter = resolveFilter(projectPath);
|
|
74
|
+
const ig = filter.useIgnoreFiles ? loadGitignore(projectPath) : null;
|
|
75
|
+
|
|
76
|
+
let rawEntries;
|
|
77
|
+
try { rawEntries = readdirSync(absDir, { withFileTypes: true }); }
|
|
78
|
+
catch { return []; }
|
|
79
|
+
|
|
80
|
+
const results: FileDirEntry[] = [];
|
|
81
|
+
|
|
82
|
+
for (const entry of rawEntries) {
|
|
83
|
+
const entryRel = relPath ? `${relPath}/${entry.name}` : entry.name;
|
|
84
|
+
const entryRelPosix = entryRel.split("\\").join("/");
|
|
85
|
+
|
|
86
|
+
// Skip entries matching filesExclude (check full path and bare name)
|
|
87
|
+
if (matchesGlob(entryRelPosix, filter.filesExclude)) continue;
|
|
88
|
+
if (matchesGlob(entry.name, filter.filesExclude)) continue;
|
|
89
|
+
|
|
90
|
+
// Gitignore flag (informational only — entry still included in list)
|
|
91
|
+
let isIgnored = false;
|
|
92
|
+
if (ig) {
|
|
93
|
+
const checkPath = entry.isDirectory() ? `${entryRelPosix}/` : entryRelPosix;
|
|
94
|
+
isIgnored = ig.ignores(checkPath) || ig.ignores(entryRelPosix);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
results.push({
|
|
98
|
+
name: entry.name,
|
|
99
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
100
|
+
isIgnored,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Sort: directories first, then alphabetically
|
|
105
|
+
results.sort((a, b) => {
|
|
106
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
107
|
+
return a.name.localeCompare(b.name);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// buildIndex — recursive flat file list
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build flat index of all files in project for palette/search.
|
|
119
|
+
* Applies filesExclude + searchExclude + optional gitignore.
|
|
120
|
+
* Result is cached; call invalidateIndexCache(projectPath) to bust.
|
|
121
|
+
*/
|
|
122
|
+
export function buildIndex(projectPath: string): FileEntry[] {
|
|
123
|
+
const cached = indexCache.get(projectPath);
|
|
124
|
+
if (cached) return cached;
|
|
125
|
+
|
|
126
|
+
const filter = resolveFilter(projectPath);
|
|
127
|
+
const ig = filter.useIgnoreFiles ? loadGitignore(projectPath) : null;
|
|
128
|
+
const allExclude = [...filter.filesExclude, ...filter.searchExclude];
|
|
129
|
+
|
|
130
|
+
const entries: FileEntry[] = [];
|
|
131
|
+
walkForIndex(projectPath, projectPath, allExclude, ig, entries);
|
|
132
|
+
|
|
133
|
+
indexCache.set(projectPath, entries);
|
|
134
|
+
return entries;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function walkForIndex(
|
|
138
|
+
rootPath: string,
|
|
139
|
+
dirPath: string,
|
|
140
|
+
allExclude: string[],
|
|
141
|
+
ig: Ignore | null,
|
|
142
|
+
results: FileEntry[],
|
|
143
|
+
): void {
|
|
144
|
+
let dirEntries;
|
|
145
|
+
try { dirEntries = readdirSync(dirPath, { withFileTypes: true }); }
|
|
146
|
+
catch { return; }
|
|
147
|
+
|
|
148
|
+
for (const entry of dirEntries) {
|
|
149
|
+
const fullPath = join(dirPath, entry.name);
|
|
150
|
+
const relPath = relative(rootPath, fullPath);
|
|
151
|
+
const relPosix = relPath.split("\\").join("/");
|
|
152
|
+
|
|
153
|
+
// Apply glob exclusion (check full relative path and bare entry name)
|
|
154
|
+
if (matchesGlob(relPosix, allExclude)) continue;
|
|
155
|
+
if (matchesGlob(entry.name, allExclude)) continue;
|
|
156
|
+
|
|
157
|
+
// Apply gitignore rules
|
|
158
|
+
if (ig) {
|
|
159
|
+
const checkPath = entry.isDirectory() ? `${relPosix}/` : relPosix;
|
|
160
|
+
if (ig.ignores(checkPath) || ig.ignores(relPosix)) continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (entry.isDirectory()) {
|
|
164
|
+
results.push({ path: relPosix, name: entry.name, type: "directory" });
|
|
165
|
+
walkForIndex(rootPath, fullPath, allExclude, ig, results);
|
|
166
|
+
} else {
|
|
167
|
+
results.push({ path: relPosix, name: entry.name, type: "file" });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -16,11 +16,12 @@ interface WatchEntry {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const watchers = new Map<string, WatchEntry>();
|
|
19
|
-
|
|
19
|
+
/** Multiple callbacks supported — each is invoked on every file change event */
|
|
20
|
+
const changeCallbacks: ChangeCallback[] = [];
|
|
20
21
|
|
|
21
|
-
/** Register callback for file change events */
|
|
22
|
+
/** Register a callback for file change events (additive — does not replace previous) */
|
|
22
23
|
export function onFileChange(cb: ChangeCallback): void {
|
|
23
|
-
|
|
24
|
+
changeCallbacks.push(cb);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function shouldIgnore(filePath: string): boolean {
|
|
@@ -47,7 +48,10 @@ export function startWatching(projectName: string, projectPath: string): void {
|
|
|
47
48
|
entry.timer = setTimeout(() => {
|
|
48
49
|
const paths = [...entry.pending];
|
|
49
50
|
entry.pending.clear();
|
|
50
|
-
for (const p of paths)
|
|
51
|
+
for (const p of paths) {
|
|
52
|
+
const relPath = p.replaceAll("\\", "/");
|
|
53
|
+
for (const cb of changeCallbacks) cb(projectName, relPath);
|
|
54
|
+
}
|
|
51
55
|
}, DEBOUNCE_MS);
|
|
52
56
|
});
|
|
53
57
|
|