@hienlh/ppm 0.12.7 → 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 (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bun.lock +2062 -0
  3. package/bunfig.toml +2 -0
  4. package/dist/web/assets/{ai-settings-section-BHdBBJtS.js → ai-settings-section-QE6nBNgN.js} +1 -1
  5. package/dist/web/assets/api-client-Dvzcc_EO.js +1 -0
  6. package/dist/web/assets/{api-settings-ByUGHhTB.js → api-settings-DAk7D-NP.js} +1 -1
  7. package/dist/web/assets/{audio-preview-A6ScJemm.js → audio-preview-DnQmf9fu.js} +1 -1
  8. package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
  9. package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
  10. package/dist/web/assets/{conflict-editor-DQt8Bap3.js → conflict-editor-BYzf3LuW.js} +1 -1
  11. package/dist/web/assets/{database-viewer-C1k-aq-e.js → database-viewer-DjvnIn8p.js} +2 -2
  12. package/dist/web/assets/{diff-viewer-TowzH722.js → diff-viewer-CP2jcR5J.js} +1 -1
  13. package/dist/web/assets/{extension-webview-Cn1x5C5F.js → extension-webview-4xMREn_x.js} +1 -1
  14. package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
  15. package/dist/web/assets/{image-preview-MGnGKiYs.js → image-preview-CkS2PVdQ.js} +1 -1
  16. package/dist/web/assets/index-BTjuH4fn.css +2 -0
  17. package/dist/web/assets/index-FGlF8IWZ.js +23 -0
  18. package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
  19. package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-DSINJjCx.js → markdown-renderer-Bj2B05Km.js} +1 -1
  21. package/dist/web/assets/{pdf-preview-BiI5Qihn.js → pdf-preview-CCyw5cuH.js} +1 -1
  22. package/dist/web/assets/{port-forwarding-tab-jjdgxhoi.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
  23. package/dist/web/assets/{postgres-viewer-BwXJ-fGk.js → postgres-viewer-BrOiliEv.js} +2 -2
  24. package/dist/web/assets/{settings-store-BMZgnYTp.js → settings-store-BLLR7ed8.js} +2 -2
  25. package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
  26. package/dist/web/assets/{sql-query-editor-BSHd21AE.js → sql-query-editor-CVAnRFbi.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-BPywcOES.js → sqlite-viewer-OEVq_-Po.js} +1 -1
  28. package/dist/web/assets/{terminal-tab-Civ2Yhce.js → terminal-tab-MjmJaQyA.js} +1 -1
  29. package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
  30. package/dist/web/assets/{use-monaco-theme-CXs7t0_G.js → use-monaco-theme-BkZDwoVd.js} +1 -1
  31. package/dist/web/assets/{video-preview-Db5TkPSt.js → video-preview-B819qvlp.js} +1 -1
  32. package/dist/web/index.html +8 -8
  33. package/dist/web/sw.js +1 -1
  34. package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
  35. package/docs/project-changelog.md +13 -1
  36. package/docs/system-architecture.md +79 -1
  37. package/package.json +1 -1
  38. package/src/index.ts +0 -0
  39. package/src/server/index.ts +1 -1
  40. package/src/server/routes/files.ts +40 -2
  41. package/src/server/routes/projects.ts +53 -0
  42. package/src/server/routes/settings.ts +50 -1
  43. package/src/services/config.service.ts +41 -0
  44. package/src/services/db.service.ts +57 -1
  45. package/src/services/file-filter.service.ts +121 -0
  46. package/src/services/file-list-index.service.ts +170 -0
  47. package/src/services/file-watcher.service.ts +8 -4
  48. package/src/services/file.service.ts +55 -53
  49. package/src/services/upgrade.service.ts +2 -2
  50. package/src/types/chat.ts +2 -1
  51. package/src/types/project.ts +31 -0
  52. package/src/web/components/chat/file-picker.tsx +0 -13
  53. package/src/web/components/chat/message-input.tsx +11 -14
  54. package/src/web/components/chat/tool-cards.tsx +4 -2
  55. package/src/web/components/explorer/file-tree.tsx +91 -26
  56. package/src/web/components/layout/command-palette.tsx +26 -3
  57. package/src/web/components/settings/files-settings-section.tsx +230 -0
  58. package/src/web/components/settings/glob-list-editor.tsx +121 -0
  59. package/src/web/components/settings/settings-tab.tsx +5 -2
  60. package/src/web/lib/api-client.ts +2 -1
  61. package/src/web/lib/api-files-settings.ts +42 -0
  62. package/src/web/stores/file-store.ts +139 -14
  63. package/src/web/stores/file-tree-merge-helpers.ts +44 -0
  64. package/src/web/stores/jira-store.ts +1 -1
  65. package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
  66. package/dist/web/assets/chat-tab--Rc7WIJp.js +0 -12
  67. package/dist/web/assets/code-editor-DZSUYMBx.js +0 -8
  68. package/dist/web/assets/index-BrAupjGV.css +0 -2
  69. package/dist/web/assets/index-gxtJiPiW.js +0 -23
  70. package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
  71. package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
  72. package/dist/web/assets/settings-tab-USIB-LOd.js +0 -1
@@ -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
 
@@ -9,11 +9,19 @@ import {
9
9
  rmSync,
10
10
  renameSync,
11
11
  } from "node:fs";
12
- import { resolve, relative, basename, dirname, join, normalize, sep } from "node:path";
12
+ import { resolve, relative, dirname, join, normalize, sep } from "node:path";
13
13
  import ignore, { type Ignore } from "ignore";
14
- import type { FileNode } from "../types/project.ts";
14
+ import type { FileNode, FileEntry, FileDirEntry } from "../types/project.ts";
15
+ import {
16
+ listDir as listDirImpl,
17
+ buildIndex as buildIndexImpl,
18
+ invalidateIndexCache,
19
+ clearIndexCache,
20
+ } from "./file-list-index.service.ts";
21
+
22
+ export { invalidateIndexCache, clearIndexCache };
15
23
 
16
- /** Directories/files excluded from tree listing */
24
+ /** Directories/files excluded from tree listing (legacy — kept for getTree back-compat) */
17
25
  const EXCLUDED_NAMES = new Set([".git", "node_modules"]);
18
26
 
19
27
  /** Load and compile gitignore rules from a project root */
@@ -103,13 +111,7 @@ class FileService {
103
111
  };
104
112
 
105
113
  if (entry.isDirectory()) {
106
- node.children = this.buildTree(
107
- rootPath,
108
- fullPath,
109
- currentDepth + 1,
110
- maxDepth,
111
- ig,
112
- );
114
+ node.children = this.buildTree(rootPath, fullPath, currentDepth + 1, maxDepth, ig);
113
115
  }
114
116
 
115
117
  nodes.push(node);
@@ -170,21 +172,14 @@ class FileService {
170
172
  }
171
173
 
172
174
  /** Read file content with encoding detection */
173
- readFile(
174
- projectPath: string,
175
- filePath: string,
176
- ): { content: string; encoding: string } {
175
+ readFile(projectPath: string, filePath: string): { content: string; encoding: string } {
177
176
  const absPath = this.resolveSafe(projectPath, filePath);
178
177
  this.blockSensitive(filePath);
179
178
 
180
- if (!existsSync(absPath)) {
181
- throw new NotFoundError(`File not found: ${filePath}`);
182
- }
179
+ if (!existsSync(absPath)) throw new NotFoundError(`File not found: ${filePath}`);
183
180
 
184
181
  const stat = statSync(absPath);
185
- if (stat.isDirectory()) {
186
- throw new ValidationError("Cannot read a directory");
187
- }
182
+ if (stat.isDirectory()) throw new ValidationError("Cannot read a directory");
188
183
 
189
184
  // Binary detection: check for null bytes in first chunk
190
185
  const buffer = readFileSync(absPath);
@@ -201,27 +196,18 @@ class FileService {
201
196
  const absPath = this.resolveSafe(projectPath, filePath);
202
197
  this.blockSensitive(filePath);
203
198
 
204
- // Ensure parent directory exists
205
199
  const dir = dirname(absPath);
206
- if (!existsSync(dir)) {
207
- mkdirSync(dir, { recursive: true });
208
- }
200
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
209
201
 
210
202
  writeFileSync(absPath, content, "utf-8");
211
203
  }
212
204
 
213
205
  /** Create a file or directory */
214
- createFile(
215
- projectPath: string,
216
- filePath: string,
217
- type: "file" | "directory",
218
- ): void {
206
+ createFile(projectPath: string, filePath: string, type: "file" | "directory"): void {
219
207
  const absPath = this.resolveSafe(projectPath, filePath);
220
208
  this.blockSensitive(filePath);
221
209
 
222
- if (existsSync(absPath)) {
223
- throw new ValidationError(`Already exists: ${filePath}`);
224
- }
210
+ if (existsSync(absPath)) throw new ValidationError(`Already exists: ${filePath}`);
225
211
 
226
212
  if (type === "directory") {
227
213
  mkdirSync(absPath, { recursive: true });
@@ -237,9 +223,7 @@ class FileService {
237
223
  const absPath = this.resolveSafe(projectPath, filePath);
238
224
  this.blockSensitive(filePath);
239
225
 
240
- if (!existsSync(absPath)) {
241
- throw new NotFoundError(`Not found: ${filePath}`);
242
- }
226
+ if (!existsSync(absPath)) throw new NotFoundError(`Not found: ${filePath}`);
243
227
 
244
228
  const stat = statSync(absPath);
245
229
  if (stat.isDirectory()) {
@@ -250,24 +234,15 @@ class FileService {
250
234
  }
251
235
 
252
236
  /** Rename a file or directory */
253
- renameFile(
254
- projectPath: string,
255
- oldPath: string,
256
- newPath: string,
257
- ): void {
237
+ renameFile(projectPath: string, oldPath: string, newPath: string): void {
258
238
  const absOld = this.resolveSafe(projectPath, oldPath);
259
239
  const absNew = this.resolveSafe(projectPath, newPath);
260
240
  this.blockSensitive(oldPath);
261
241
  this.blockSensitive(newPath);
262
242
 
263
- if (!existsSync(absOld)) {
264
- throw new NotFoundError(`Not found: ${oldPath}`);
265
- }
266
- if (existsSync(absNew)) {
267
- throw new ValidationError(`Already exists: ${newPath}`);
268
- }
243
+ if (!existsSync(absOld)) throw new NotFoundError(`Not found: ${oldPath}`);
244
+ if (existsSync(absNew)) throw new ValidationError(`Already exists: ${newPath}`);
269
245
 
270
- // Ensure parent dir of new path exists
271
246
  const dir = dirname(absNew);
272
247
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
273
248
 
@@ -275,15 +250,26 @@ class FileService {
275
250
  }
276
251
 
277
252
  /** Move a file or directory to a new location */
278
- moveFile(
279
- projectPath: string,
280
- source: string,
281
- destination: string,
282
- ): void {
283
- // Move is functionally the same as rename
253
+ moveFile(projectPath: string, source: string, destination: string): void {
284
254
  this.renameFile(projectPath, source, destination);
285
255
  }
286
256
 
257
+ /**
258
+ * List one directory level for lazy-load file tree (delegates to file-list-index.service).
259
+ * Applies filesExclude patterns; returns gitignore flag per entry.
260
+ */
261
+ listDir(projectPath: string, relPath: string): FileDirEntry[] {
262
+ return listDirImpl(projectPath, relPath);
263
+ }
264
+
265
+ /**
266
+ * Build flat file index for palette/search (delegates to file-list-index.service).
267
+ * Cached per project; invalidated on file change via invalidateIndexCache().
268
+ */
269
+ buildIndex(projectPath: string): FileEntry[] {
270
+ return buildIndexImpl(projectPath);
271
+ }
272
+
287
273
  /** Block access to sensitive paths (.git/) */
288
274
  private blockSensitive(filePath: string): void {
289
275
  const normalized = normalize(filePath);
@@ -319,3 +305,19 @@ export class ValidationError extends Error {
319
305
  }
320
306
 
321
307
  export const fileService = new FileService();
308
+
309
+ // Wire file watcher → index cache invalidation
310
+ // Dynamic import avoids circular dependency (file-watcher → chat.ts → file.service)
311
+ import("./file-watcher.service.ts").then(({ onFileChange }) => {
312
+ onFileChange((projectName) => {
313
+ try {
314
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
315
+ const { configService } = require("./config.service.ts");
316
+ const projects = configService.get("projects") as Array<{ name: string; path: string }>;
317
+ const project = projects.find((p: { name: string }) => p.name === projectName);
318
+ if (project) invalidateIndexCache(project.path);
319
+ } catch {
320
+ // Config not yet loaded or project not found — skip invalidation
321
+ }
322
+ });
323
+ }).catch(() => { /* file-watcher unavailable in test/CLI context */ });
@@ -23,8 +23,8 @@ export function getInstallMethod(): InstallMethod {
23
23
  /** Compare two semver strings (ignores pre-release tags). Returns -1 (a < b), 0 (equal), 1 (a > b) */
24
24
  export function compareSemver(a: string, b: string): -1 | 0 | 1 {
25
25
  // Strip pre-release suffix (e.g. "1.0.0-beta.1" → "1.0.0")
26
- const pa = a.split("-")[0].split(".").map(Number);
27
- const pb = b.split("-")[0].split(".").map(Number);
26
+ const pa = (a.split("-")[0] ?? "0").split(".").map(Number);
27
+ const pb = (b.split("-")[0] ?? "0").split(".").map(Number);
28
28
  for (let i = 0; i < 3; i++) {
29
29
  const va = pa[i] ?? 0;
30
30
  const vb = pb[i] ?? 0;
package/src/types/chat.ts CHANGED
@@ -132,7 +132,8 @@ export type ChatEvent =
132
132
  | { type: "system"; subtype: string }
133
133
  | { type: "team_detected"; teamName: string }
134
134
  | { type: "team_updated"; teamName: string; team: unknown }
135
- | { type: "team_inbox"; teamName: string; agent: string; messages: unknown[] };
135
+ | { type: "team_inbox"; teamName: string; agent: string; messages: unknown[] }
136
+ | { type: "session_migrated"; oldSessionId: string; newSessionId: string };
136
137
 
137
138
  export type ToolApprovalHandler = (
138
139
  tool: string,
@@ -19,3 +19,34 @@ export interface FileNode {
19
19
  /** True if this path is matched by a .gitignore rule */
20
20
  ignored?: boolean;
21
21
  }
22
+
23
+ /** A flat file entry returned by /files/index */
24
+ export interface FileEntry {
25
+ path: string;
26
+ name: string;
27
+ type: "file" | "directory";
28
+ }
29
+
30
+ /** Entry returned by /files/list (single directory level) */
31
+ export interface FileDirEntry {
32
+ name: string;
33
+ type: "file" | "directory";
34
+ /** True if entry is excluded by gitignore (informational — still listed) */
35
+ isIgnored: boolean;
36
+ }
37
+
38
+ /** Per-project file filter override (stored in projects.settings JSON) */
39
+ export interface FileFilterConfig {
40
+ /** Additional glob patterns to exclude from tree/list */
41
+ filesExclude?: string[];
42
+ /** Additional glob patterns to exclude from index/search */
43
+ searchExclude?: string[];
44
+ /** Whether to use .gitignore rules (null = use global setting) */
45
+ useIgnoreFiles?: boolean;
46
+ }
47
+
48
+ /** Per-project settings stored in projects.settings JSON column */
49
+ export interface ProjectSettings {
50
+ files?: FileFilterConfig;
51
+ [key: string]: unknown;
52
+ }
@@ -10,19 +10,6 @@ interface FilePickerProps {
10
10
  visible: boolean;
11
11
  }
12
12
 
13
- /** Flatten a FileNode tree into a flat list of files and directories. */
14
- export function flattenFileTree(nodes: FileNode[]): FileNode[] {
15
- const result: FileNode[] = [];
16
- function walk(list: FileNode[]) {
17
- for (const node of list) {
18
- result.push(node);
19
- if (node.children) walk(node.children);
20
- }
21
- }
22
- walk(nodes);
23
- return result;
24
- }
25
-
26
13
  export function FilePicker({
27
14
  items,
28
15
  filter,