@hienlh/ppm 0.12.6 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/web/assets/{ai-settings-section-BHdBBJtS.js → ai-settings-section-QE6nBNgN.js} +1 -1
  3. package/dist/web/assets/api-client-Dvzcc_EO.js +1 -0
  4. package/dist/web/assets/{api-settings-ByUGHhTB.js → api-settings-DAk7D-NP.js} +1 -1
  5. package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +1 -0
  6. package/dist/web/assets/{audio-preview-BMmzgbUs.js → audio-preview-DnQmf9fu.js} +1 -1
  7. package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
  8. package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
  9. package/dist/web/assets/{conflict-editor-CBietP8L.js → conflict-editor-BYzf3LuW.js} +1 -1
  10. package/dist/web/assets/{database-viewer-CZgooyFp.js → database-viewer-DjvnIn8p.js} +2 -2
  11. package/dist/web/assets/{diff-viewer-BVYjlTcF.js → diff-viewer-CP2jcR5J.js} +1 -1
  12. package/dist/web/assets/{extension-webview-DyZOGDb1.js → extension-webview-4xMREn_x.js} +1 -1
  13. package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
  14. package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +1 -0
  15. package/dist/web/assets/github-dark-dimmed.min-BrpRStFV.css +1 -0
  16. package/dist/web/assets/github.min-D2BCvnWf.css +1 -0
  17. package/dist/web/assets/{image-preview-k8_kzoHe.js → image-preview-CkS2PVdQ.js} +1 -1
  18. package/dist/web/assets/index-BTjuH4fn.css +2 -0
  19. package/dist/web/assets/index-FGlF8IWZ.js +23 -0
  20. package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +1 -0
  21. package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
  22. package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
  23. package/dist/web/assets/{markdown-renderer-CJOPseDk.js → markdown-renderer-Bj2B05Km.js} +3 -3
  24. package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +1 -0
  25. package/dist/web/assets/{pdf-preview-GCIIaZVw.js → pdf-preview-CCyw5cuH.js} +1 -1
  26. package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +1 -0
  27. package/dist/web/assets/{port-forwarding-tab-DzLa02_D.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
  28. package/dist/web/assets/{postgres-viewer-JCT24Yqh.js → postgres-viewer-BrOiliEv.js} +2 -2
  29. package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +1 -0
  30. package/dist/web/assets/settings-store-BLLR7ed8.js +2 -0
  31. package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
  32. package/dist/web/assets/{sql-query-editor-JwymAmuK.js → sql-query-editor-CVAnRFbi.js} +1 -1
  33. package/dist/web/assets/{sqlite-viewer-nA_Biwex.js → sqlite-viewer-OEVq_-Po.js} +1 -1
  34. package/dist/web/assets/{terminal-tab-DvKxdDv4.js → terminal-tab-MjmJaQyA.js} +1 -1
  35. package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +1 -0
  36. package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
  37. package/dist/web/assets/{use-monaco-theme-o7Ip-BDL.js → use-monaco-theme-BkZDwoVd.js} +1 -1
  38. package/dist/web/assets/{vendor-mermaid-BlWh9BJO.js → vendor-mermaid-Dx86tuVP.js} +1 -1
  39. package/dist/web/assets/{video-preview-CAGgINCA.js → video-preview-B819qvlp.js} +1 -1
  40. package/dist/web/index.html +10 -10
  41. package/dist/web/sw.js +1 -1
  42. package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
  43. package/docs/project-changelog.md +13 -1
  44. package/docs/system-architecture.md +79 -1
  45. package/package.json +1 -1
  46. package/src/server/index.ts +1 -1
  47. package/src/server/routes/files.ts +40 -2
  48. package/src/server/routes/projects.ts +53 -0
  49. package/src/server/routes/settings.ts +50 -1
  50. package/src/services/config.service.ts +41 -0
  51. package/src/services/db.service.ts +57 -1
  52. package/src/services/file-filter.service.ts +121 -0
  53. package/src/services/file-list-index.service.ts +170 -0
  54. package/src/services/file-watcher.service.ts +8 -4
  55. package/src/services/file.service.ts +55 -53
  56. package/src/services/upgrade.service.ts +2 -2
  57. package/src/types/chat.ts +2 -1
  58. package/src/types/project.ts +31 -0
  59. package/src/web/components/chat/file-picker.tsx +0 -13
  60. package/src/web/components/chat/message-input.tsx +11 -14
  61. package/src/web/components/chat/tool-cards.tsx +4 -2
  62. package/src/web/components/explorer/file-tree.tsx +91 -26
  63. package/src/web/components/layout/command-palette.tsx +26 -3
  64. package/src/web/components/settings/files-settings-section.tsx +230 -0
  65. package/src/web/components/settings/glob-list-editor.tsx +121 -0
  66. package/src/web/components/settings/settings-tab.tsx +5 -2
  67. package/src/web/lib/api-client.ts +2 -1
  68. package/src/web/lib/api-files-settings.ts +42 -0
  69. package/src/web/main.tsx +1 -1
  70. package/src/web/stores/file-store.ts +139 -14
  71. package/src/web/stores/file-tree-merge-helpers.ts +44 -0
  72. package/src/web/stores/jira-store.ts +1 -1
  73. package/src/web/stores/settings-store.ts +20 -0
  74. package/src/web/styles/globals.css +2 -8
  75. package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
  76. package/dist/web/assets/architecture-PBZL5I3N-XX6_EZsC.js +0 -1
  77. package/dist/web/assets/chat-tab-NteLsEST.js +0 -12
  78. package/dist/web/assets/code-editor-Da9GXN5w.js +0 -8
  79. package/dist/web/assets/gitGraph-HDMCJU4V-BhjTKsbg.js +0 -1
  80. package/dist/web/assets/index-CDSox8V2.css +0 -2
  81. package/dist/web/assets/index-CXR1vYHY.js +0 -23
  82. package/dist/web/assets/info-3K5VOQVL-CzgVqYTx.js +0 -1
  83. package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
  84. package/dist/web/assets/packet-RMMSAZCW-C7agXrtd.js +0 -1
  85. package/dist/web/assets/pie-UPGHQEXC-BRZ7alnf.js +0 -1
  86. package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
  87. package/dist/web/assets/radar-KQ55EAFF-DSn_ekR5.js +0 -1
  88. package/dist/web/assets/settings-store-fDOEursg.js +0 -2
  89. package/dist/web/assets/settings-tab-bYmVV0Ww.js +0 -1
  90. package/dist/web/assets/treemap-KZPCXAKY-C8puYVyN.js +0 -1
@@ -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 = 20;
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
- let changeCallback: ChangeCallback | null = null;
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
- changeCallback = cb;
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) changeCallback?.(projectName, p.replaceAll("\\", "/"));
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