@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.
- package/CHANGELOG.md +8 -0
- package/bun.lock +2062 -0
- package/bunfig.toml +2 -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/index.ts +0 -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
|
@@ -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
|
|
|
@@ -9,11 +9,19 @@ import {
|
|
|
9
9
|
rmSync,
|
|
10
10
|
renameSync,
|
|
11
11
|
} from "node:fs";
|
|
12
|
-
import { resolve, relative,
|
|
12
|
+
import { resolve, relative, dirname, join, normalize, sep } from "node:path";
|
|
13
13
|
import ignore, { type Ignore } from "ignore";
|
|
14
|
-
import type { FileNode } from "../types/project.ts";
|
|
14
|
+
import type { FileNode, FileEntry, FileDirEntry } from "../types/project.ts";
|
|
15
|
+
import {
|
|
16
|
+
listDir as listDirImpl,
|
|
17
|
+
buildIndex as buildIndexImpl,
|
|
18
|
+
invalidateIndexCache,
|
|
19
|
+
clearIndexCache,
|
|
20
|
+
} from "./file-list-index.service.ts";
|
|
21
|
+
|
|
22
|
+
export { invalidateIndexCache, clearIndexCache };
|
|
15
23
|
|
|
16
|
-
/** Directories/files excluded from tree listing */
|
|
24
|
+
/** Directories/files excluded from tree listing (legacy — kept for getTree back-compat) */
|
|
17
25
|
const EXCLUDED_NAMES = new Set([".git", "node_modules"]);
|
|
18
26
|
|
|
19
27
|
/** Load and compile gitignore rules from a project root */
|
|
@@ -103,13 +111,7 @@ class FileService {
|
|
|
103
111
|
};
|
|
104
112
|
|
|
105
113
|
if (entry.isDirectory()) {
|
|
106
|
-
node.children = this.buildTree(
|
|
107
|
-
rootPath,
|
|
108
|
-
fullPath,
|
|
109
|
-
currentDepth + 1,
|
|
110
|
-
maxDepth,
|
|
111
|
-
ig,
|
|
112
|
-
);
|
|
114
|
+
node.children = this.buildTree(rootPath, fullPath, currentDepth + 1, maxDepth, ig);
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
nodes.push(node);
|
|
@@ -170,21 +172,14 @@ class FileService {
|
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
/** Read file content with encoding detection */
|
|
173
|
-
readFile(
|
|
174
|
-
projectPath: string,
|
|
175
|
-
filePath: string,
|
|
176
|
-
): { content: string; encoding: string } {
|
|
175
|
+
readFile(projectPath: string, filePath: string): { content: string; encoding: string } {
|
|
177
176
|
const absPath = this.resolveSafe(projectPath, filePath);
|
|
178
177
|
this.blockSensitive(filePath);
|
|
179
178
|
|
|
180
|
-
if (!existsSync(absPath)) {
|
|
181
|
-
throw new NotFoundError(`File not found: ${filePath}`);
|
|
182
|
-
}
|
|
179
|
+
if (!existsSync(absPath)) throw new NotFoundError(`File not found: ${filePath}`);
|
|
183
180
|
|
|
184
181
|
const stat = statSync(absPath);
|
|
185
|
-
if (stat.isDirectory())
|
|
186
|
-
throw new ValidationError("Cannot read a directory");
|
|
187
|
-
}
|
|
182
|
+
if (stat.isDirectory()) throw new ValidationError("Cannot read a directory");
|
|
188
183
|
|
|
189
184
|
// Binary detection: check for null bytes in first chunk
|
|
190
185
|
const buffer = readFileSync(absPath);
|
|
@@ -201,27 +196,18 @@ class FileService {
|
|
|
201
196
|
const absPath = this.resolveSafe(projectPath, filePath);
|
|
202
197
|
this.blockSensitive(filePath);
|
|
203
198
|
|
|
204
|
-
// Ensure parent directory exists
|
|
205
199
|
const dir = dirname(absPath);
|
|
206
|
-
if (!existsSync(dir)) {
|
|
207
|
-
mkdirSync(dir, { recursive: true });
|
|
208
|
-
}
|
|
200
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
209
201
|
|
|
210
202
|
writeFileSync(absPath, content, "utf-8");
|
|
211
203
|
}
|
|
212
204
|
|
|
213
205
|
/** Create a file or directory */
|
|
214
|
-
createFile(
|
|
215
|
-
projectPath: string,
|
|
216
|
-
filePath: string,
|
|
217
|
-
type: "file" | "directory",
|
|
218
|
-
): void {
|
|
206
|
+
createFile(projectPath: string, filePath: string, type: "file" | "directory"): void {
|
|
219
207
|
const absPath = this.resolveSafe(projectPath, filePath);
|
|
220
208
|
this.blockSensitive(filePath);
|
|
221
209
|
|
|
222
|
-
if (existsSync(absPath)) {
|
|
223
|
-
throw new ValidationError(`Already exists: ${filePath}`);
|
|
224
|
-
}
|
|
210
|
+
if (existsSync(absPath)) throw new ValidationError(`Already exists: ${filePath}`);
|
|
225
211
|
|
|
226
212
|
if (type === "directory") {
|
|
227
213
|
mkdirSync(absPath, { recursive: true });
|
|
@@ -237,9 +223,7 @@ class FileService {
|
|
|
237
223
|
const absPath = this.resolveSafe(projectPath, filePath);
|
|
238
224
|
this.blockSensitive(filePath);
|
|
239
225
|
|
|
240
|
-
if (!existsSync(absPath)) {
|
|
241
|
-
throw new NotFoundError(`Not found: ${filePath}`);
|
|
242
|
-
}
|
|
226
|
+
if (!existsSync(absPath)) throw new NotFoundError(`Not found: ${filePath}`);
|
|
243
227
|
|
|
244
228
|
const stat = statSync(absPath);
|
|
245
229
|
if (stat.isDirectory()) {
|
|
@@ -250,24 +234,15 @@ class FileService {
|
|
|
250
234
|
}
|
|
251
235
|
|
|
252
236
|
/** Rename a file or directory */
|
|
253
|
-
renameFile(
|
|
254
|
-
projectPath: string,
|
|
255
|
-
oldPath: string,
|
|
256
|
-
newPath: string,
|
|
257
|
-
): void {
|
|
237
|
+
renameFile(projectPath: string, oldPath: string, newPath: string): void {
|
|
258
238
|
const absOld = this.resolveSafe(projectPath, oldPath);
|
|
259
239
|
const absNew = this.resolveSafe(projectPath, newPath);
|
|
260
240
|
this.blockSensitive(oldPath);
|
|
261
241
|
this.blockSensitive(newPath);
|
|
262
242
|
|
|
263
|
-
if (!existsSync(absOld)) {
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
if (existsSync(absNew)) {
|
|
267
|
-
throw new ValidationError(`Already exists: ${newPath}`);
|
|
268
|
-
}
|
|
243
|
+
if (!existsSync(absOld)) throw new NotFoundError(`Not found: ${oldPath}`);
|
|
244
|
+
if (existsSync(absNew)) throw new ValidationError(`Already exists: ${newPath}`);
|
|
269
245
|
|
|
270
|
-
// Ensure parent dir of new path exists
|
|
271
246
|
const dir = dirname(absNew);
|
|
272
247
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
273
248
|
|
|
@@ -275,15 +250,26 @@ class FileService {
|
|
|
275
250
|
}
|
|
276
251
|
|
|
277
252
|
/** Move a file or directory to a new location */
|
|
278
|
-
moveFile(
|
|
279
|
-
projectPath: string,
|
|
280
|
-
source: string,
|
|
281
|
-
destination: string,
|
|
282
|
-
): void {
|
|
283
|
-
// Move is functionally the same as rename
|
|
253
|
+
moveFile(projectPath: string, source: string, destination: string): void {
|
|
284
254
|
this.renameFile(projectPath, source, destination);
|
|
285
255
|
}
|
|
286
256
|
|
|
257
|
+
/**
|
|
258
|
+
* List one directory level for lazy-load file tree (delegates to file-list-index.service).
|
|
259
|
+
* Applies filesExclude patterns; returns gitignore flag per entry.
|
|
260
|
+
*/
|
|
261
|
+
listDir(projectPath: string, relPath: string): FileDirEntry[] {
|
|
262
|
+
return listDirImpl(projectPath, relPath);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build flat file index for palette/search (delegates to file-list-index.service).
|
|
267
|
+
* Cached per project; invalidated on file change via invalidateIndexCache().
|
|
268
|
+
*/
|
|
269
|
+
buildIndex(projectPath: string): FileEntry[] {
|
|
270
|
+
return buildIndexImpl(projectPath);
|
|
271
|
+
}
|
|
272
|
+
|
|
287
273
|
/** Block access to sensitive paths (.git/) */
|
|
288
274
|
private blockSensitive(filePath: string): void {
|
|
289
275
|
const normalized = normalize(filePath);
|
|
@@ -319,3 +305,19 @@ export class ValidationError extends Error {
|
|
|
319
305
|
}
|
|
320
306
|
|
|
321
307
|
export const fileService = new FileService();
|
|
308
|
+
|
|
309
|
+
// Wire file watcher → index cache invalidation
|
|
310
|
+
// Dynamic import avoids circular dependency (file-watcher → chat.ts → file.service)
|
|
311
|
+
import("./file-watcher.service.ts").then(({ onFileChange }) => {
|
|
312
|
+
onFileChange((projectName) => {
|
|
313
|
+
try {
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
315
|
+
const { configService } = require("./config.service.ts");
|
|
316
|
+
const projects = configService.get("projects") as Array<{ name: string; path: string }>;
|
|
317
|
+
const project = projects.find((p: { name: string }) => p.name === projectName);
|
|
318
|
+
if (project) invalidateIndexCache(project.path);
|
|
319
|
+
} catch {
|
|
320
|
+
// Config not yet loaded or project not found — skip invalidation
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}).catch(() => { /* file-watcher unavailable in test/CLI context */ });
|
|
@@ -23,8 +23,8 @@ export function getInstallMethod(): InstallMethod {
|
|
|
23
23
|
/** Compare two semver strings (ignores pre-release tags). Returns -1 (a < b), 0 (equal), 1 (a > b) */
|
|
24
24
|
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
|
|
25
25
|
// Strip pre-release suffix (e.g. "1.0.0-beta.1" → "1.0.0")
|
|
26
|
-
const pa = a.split("-")[0].split(".").map(Number);
|
|
27
|
-
const pb = b.split("-")[0].split(".").map(Number);
|
|
26
|
+
const pa = (a.split("-")[0] ?? "0").split(".").map(Number);
|
|
27
|
+
const pb = (b.split("-")[0] ?? "0").split(".").map(Number);
|
|
28
28
|
for (let i = 0; i < 3; i++) {
|
|
29
29
|
const va = pa[i] ?? 0;
|
|
30
30
|
const vb = pb[i] ?? 0;
|
package/src/types/chat.ts
CHANGED
|
@@ -132,7 +132,8 @@ export type ChatEvent =
|
|
|
132
132
|
| { type: "system"; subtype: string }
|
|
133
133
|
| { type: "team_detected"; teamName: string }
|
|
134
134
|
| { type: "team_updated"; teamName: string; team: unknown }
|
|
135
|
-
| { type: "team_inbox"; teamName: string; agent: string; messages: unknown[] }
|
|
135
|
+
| { type: "team_inbox"; teamName: string; agent: string; messages: unknown[] }
|
|
136
|
+
| { type: "session_migrated"; oldSessionId: string; newSessionId: string };
|
|
136
137
|
|
|
137
138
|
export type ToolApprovalHandler = (
|
|
138
139
|
tool: string,
|
package/src/types/project.ts
CHANGED
|
@@ -19,3 +19,34 @@ export interface FileNode {
|
|
|
19
19
|
/** True if this path is matched by a .gitignore rule */
|
|
20
20
|
ignored?: boolean;
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
/** A flat file entry returned by /files/index */
|
|
24
|
+
export interface FileEntry {
|
|
25
|
+
path: string;
|
|
26
|
+
name: string;
|
|
27
|
+
type: "file" | "directory";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Entry returned by /files/list (single directory level) */
|
|
31
|
+
export interface FileDirEntry {
|
|
32
|
+
name: string;
|
|
33
|
+
type: "file" | "directory";
|
|
34
|
+
/** True if entry is excluded by gitignore (informational — still listed) */
|
|
35
|
+
isIgnored: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Per-project file filter override (stored in projects.settings JSON) */
|
|
39
|
+
export interface FileFilterConfig {
|
|
40
|
+
/** Additional glob patterns to exclude from tree/list */
|
|
41
|
+
filesExclude?: string[];
|
|
42
|
+
/** Additional glob patterns to exclude from index/search */
|
|
43
|
+
searchExclude?: string[];
|
|
44
|
+
/** Whether to use .gitignore rules (null = use global setting) */
|
|
45
|
+
useIgnoreFiles?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Per-project settings stored in projects.settings JSON column */
|
|
49
|
+
export interface ProjectSettings {
|
|
50
|
+
files?: FileFilterConfig;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
@@ -10,19 +10,6 @@ interface FilePickerProps {
|
|
|
10
10
|
visible: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/** Flatten a FileNode tree into a flat list of files and directories. */
|
|
14
|
-
export function flattenFileTree(nodes: FileNode[]): FileNode[] {
|
|
15
|
-
const result: FileNode[] = [];
|
|
16
|
-
function walk(list: FileNode[]) {
|
|
17
|
-
for (const node of list) {
|
|
18
|
-
result.push(node);
|
|
19
|
-
if (node.children) walk(node.children);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
walk(nodes);
|
|
23
|
-
return result;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
13
|
export function FilePicker({
|
|
27
14
|
items,
|
|
28
15
|
filter,
|