brainbank 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +155 -0
- package/assets/architecture.png +0 -0
- package/bin/brainbank +18 -0
- package/bin/brainbank-mcp +19 -0
- package/dist/chunk-3YBCD6DI.js +117 -0
- package/dist/chunk-3YBCD6DI.js.map +1 -0
- package/dist/chunk-63GBCDS5.js +3249 -0
- package/dist/chunk-63GBCDS5.js.map +1 -0
- package/dist/chunk-DMFMTOHF.js +123 -0
- package/dist/chunk-DMFMTOHF.js.map +1 -0
- package/dist/chunk-FQYKWB2Q.js +136 -0
- package/dist/chunk-FQYKWB2Q.js.map +1 -0
- package/dist/chunk-IMJJ2VEM.js +74 -0
- package/dist/chunk-IMJJ2VEM.js.map +1 -0
- package/dist/chunk-M744PCJQ.js +43 -0
- package/dist/chunk-M744PCJQ.js.map +1 -0
- package/dist/chunk-O3J6ZIXK.js +82 -0
- package/dist/chunk-O3J6ZIXK.js.map +1 -0
- package/dist/chunk-OPH7GZ7U.js +124 -0
- package/dist/chunk-OPH7GZ7U.js.map +1 -0
- package/dist/chunk-PXEWQMN7.js +89 -0
- package/dist/chunk-PXEWQMN7.js.map +1 -0
- package/dist/chunk-RDQYDLYZ.js +69 -0
- package/dist/chunk-RDQYDLYZ.js.map +1 -0
- package/dist/chunk-VIIHPCC4.js +254 -0
- package/dist/chunk-VIIHPCC4.js.map +1 -0
- package/dist/chunk-WCQVDF3K.js +14 -0
- package/dist/chunk-WCQVDF3K.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3076 -0
- package/dist/cli.js.map +1 -0
- package/dist/haiku-expander-YRSIPGKP.js +8 -0
- package/dist/haiku-expander-YRSIPGKP.js.map +1 -0
- package/dist/haiku-pruner-SHAXUPY6.js +8 -0
- package/dist/haiku-pruner-SHAXUPY6.js.map +1 -0
- package/dist/http-server-QUXHLWUM.js +9 -0
- package/dist/http-server-QUXHLWUM.js.map +1 -0
- package/dist/index.d.ts +2161 -0
- package/dist/index.js +357 -0
- package/dist/index.js.map +1 -0
- package/dist/local-embedding-NZQTILGV.js +8 -0
- package/dist/local-embedding-NZQTILGV.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +334 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openai-embedding-ZP5TSUJG.js +8 -0
- package/dist/openai-embedding-ZP5TSUJG.js.map +1 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
- package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
- package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
- package/dist/plugin-IKQ6IRSJ.js +32 -0
- package/dist/plugin-IKQ6IRSJ.js.map +1 -0
- package/dist/resolve-ASGLBNUC.js +10 -0
- package/dist/resolve-ASGLBNUC.js.map +1 -0
- package/dist/stats-tui-ZY2NQSEA.js +1904 -0
- package/dist/stats-tui-ZY2NQSEA.js.map +1 -0
- package/package.json +96 -0
- package/src/brainbank.ts +617 -0
- package/src/cli/commands/collection.ts +77 -0
- package/src/cli/commands/context.ts +179 -0
- package/src/cli/commands/daemon.ts +100 -0
- package/src/cli/commands/docs.ts +71 -0
- package/src/cli/commands/files.ts +69 -0
- package/src/cli/commands/help.ts +77 -0
- package/src/cli/commands/index.ts +482 -0
- package/src/cli/commands/kv.ts +140 -0
- package/src/cli/commands/mcp-export.ts +273 -0
- package/src/cli/commands/mcp.ts +6 -0
- package/src/cli/commands/reembed.ts +30 -0
- package/src/cli/commands/scan.ts +336 -0
- package/src/cli/commands/search.ts +203 -0
- package/src/cli/commands/stats.ts +68 -0
- package/src/cli/commands/status.ts +47 -0
- package/src/cli/commands/watch.ts +47 -0
- package/src/cli/factory/brain-context.ts +43 -0
- package/src/cli/factory/builtin-registration.ts +87 -0
- package/src/cli/factory/config-loader.ts +77 -0
- package/src/cli/factory/index.ts +69 -0
- package/src/cli/factory/plugin-loader.ts +325 -0
- package/src/cli/index.ts +71 -0
- package/src/cli/server-client.ts +178 -0
- package/src/cli/tui/index-tui.tsx +667 -0
- package/src/cli/tui/stats-data.ts +523 -0
- package/src/cli/tui/stats-search.ts +262 -0
- package/src/cli/tui/stats-tui.tsx +1465 -0
- package/src/cli/tui/tree-scanner.ts +650 -0
- package/src/cli/utils.ts +137 -0
- package/src/config.ts +49 -0
- package/src/constants.ts +21 -0
- package/src/db/adapter.ts +112 -0
- package/src/db/metadata.ts +130 -0
- package/src/db/migrations.ts +66 -0
- package/src/db/sqlite-adapter.ts +218 -0
- package/src/db/tracker.ts +91 -0
- package/src/engine/index-api.ts +81 -0
- package/src/engine/reembed.ts +206 -0
- package/src/engine/search-api.ts +218 -0
- package/src/index.ts +154 -0
- package/src/lib/fts.ts +57 -0
- package/src/lib/languages.ts +180 -0
- package/src/lib/logger.ts +126 -0
- package/src/lib/math.ts +87 -0
- package/src/lib/provider-key.ts +20 -0
- package/src/lib/prune.ts +71 -0
- package/src/lib/rrf.ts +133 -0
- package/src/lib/write-lock.ts +108 -0
- package/src/mcp/mcp-server.ts +195 -0
- package/src/mcp/workspace-factory.ts +68 -0
- package/src/mcp/workspace-pool.ts +224 -0
- package/src/plugin.ts +381 -0
- package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
- package/src/providers/embeddings/embedding-worker.ts +141 -0
- package/src/providers/embeddings/local-embedding.ts +115 -0
- package/src/providers/embeddings/openai-embedding.ts +167 -0
- package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
- package/src/providers/embeddings/perplexity-embedding.ts +165 -0
- package/src/providers/embeddings/resolve.ts +34 -0
- package/src/providers/pruners/haiku-expander.ts +166 -0
- package/src/providers/pruners/haiku-pruner.ts +112 -0
- package/src/providers/vector/hnsw-index.ts +174 -0
- package/src/providers/vector/hnsw-loader.ts +129 -0
- package/src/search/bm25-boost.ts +69 -0
- package/src/search/context-builder.ts +251 -0
- package/src/search/keyword/composite-bm25-search.ts +47 -0
- package/src/search/types.ts +37 -0
- package/src/search/vector/composite-vector-search.ts +61 -0
- package/src/search/vector/mmr.ts +64 -0
- package/src/services/collection.ts +384 -0
- package/src/services/daemon.ts +87 -0
- package/src/services/http-server.ts +336 -0
- package/src/services/kv-service.ts +64 -0
- package/src/services/plugin-registry.ts +77 -0
- package/src/services/watch.ts +340 -0
- package/src/services/webhook-server.ts +100 -0
- package/src/types.ts +493 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree-scanner.ts — Filesystem scanner for the index TUI.
|
|
3
|
+
*
|
|
4
|
+
* Builds interactive file tree data (dirs + files) for navigation.
|
|
5
|
+
* Pure functions, no React, no state. Reuses existing language filters.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import picomatch from 'picomatch';
|
|
12
|
+
import { SUPPORTED_EXTENSIONS, isIgnoredDir } from '@/lib/languages.ts';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
// ── Types ─────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** A single item in the interactive file tree (dir or file). */
|
|
18
|
+
export interface FileTreeItem {
|
|
19
|
+
/** Relative path from repo root. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** Display name (basename). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Nesting depth (0 = top-level). */
|
|
24
|
+
depth: number;
|
|
25
|
+
/** Is this a directory? */
|
|
26
|
+
isDir: boolean;
|
|
27
|
+
/** File extension (e.g. '.ts'). Empty for dirs. */
|
|
28
|
+
ext: string;
|
|
29
|
+
/** Whether included for indexing. Only togglable on dirs. */
|
|
30
|
+
checked: boolean;
|
|
31
|
+
/** Whether children are visible (dirs only). */
|
|
32
|
+
expanded: boolean;
|
|
33
|
+
/** Has indexable content below (dirs only). */
|
|
34
|
+
hasChildren: boolean;
|
|
35
|
+
/** Indexable file count (dirs only, recursive). */
|
|
36
|
+
fileCount: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// ── Extension colors (VSCode-inspired) ────────────────
|
|
41
|
+
|
|
42
|
+
const EXT_COLORS: Record<string, string> = {
|
|
43
|
+
'.ts': '#519ABA',
|
|
44
|
+
'.tsx': '#519ABA',
|
|
45
|
+
'.js': '#CBCB41',
|
|
46
|
+
'.jsx': '#61DAFB',
|
|
47
|
+
'.mjs': '#CBCB41',
|
|
48
|
+
'.py': '#4B8BBE',
|
|
49
|
+
'.go': '#7FD5EA',
|
|
50
|
+
'.rs': '#DEA584',
|
|
51
|
+
'.rb': '#CC3E44',
|
|
52
|
+
'.java': '#CC3E44',
|
|
53
|
+
'.c': '#599EFF',
|
|
54
|
+
'.cpp': '#599EFF',
|
|
55
|
+
'.h': '#926BD4',
|
|
56
|
+
'.cs': '#68217A',
|
|
57
|
+
'.php': '#777BB3',
|
|
58
|
+
'.swift': '#F05138',
|
|
59
|
+
'.kt': '#7F52FF',
|
|
60
|
+
'.css': '#42A5F5',
|
|
61
|
+
'.scss': '#F06292',
|
|
62
|
+
'.html': '#E44D26',
|
|
63
|
+
'.vue': '#8DC149',
|
|
64
|
+
'.svelte': '#FF3E00',
|
|
65
|
+
'.json': '#CBCB41',
|
|
66
|
+
'.yaml': '#F44336',
|
|
67
|
+
'.yml': '#F44336',
|
|
68
|
+
'.md': '#519ABA',
|
|
69
|
+
'.sql': '#E0B040',
|
|
70
|
+
'.sh': '#89E051',
|
|
71
|
+
'.bash': '#89E051',
|
|
72
|
+
'.zsh': '#89E051',
|
|
73
|
+
'.lua': '#51A0CF',
|
|
74
|
+
'.zig': '#F69A1B',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Get the display color for a file extension. */
|
|
78
|
+
export function getExtColor(ext: string): string {
|
|
79
|
+
return EXT_COLORS[ext] ?? '#7C8DA6';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Get a short icon-like label for the extension. */
|
|
83
|
+
export function getExtIcon(ext: string): string {
|
|
84
|
+
switch (ext) {
|
|
85
|
+
case '.ts': case '.tsx': return '⬡';
|
|
86
|
+
case '.js': case '.jsx': case '.mjs': return '⬡';
|
|
87
|
+
case '.py': return '◆';
|
|
88
|
+
case '.go': return '◇';
|
|
89
|
+
case '.rs': return '⛭';
|
|
90
|
+
case '.md': return '◎';
|
|
91
|
+
case '.json': case '.yaml': case '.yml': return '◉';
|
|
92
|
+
case '.css': case '.scss': return '◈';
|
|
93
|
+
case '.html': case '.vue': case '.svelte': return '◇';
|
|
94
|
+
case '.sh': case '.bash': case '.zsh': return '⚙';
|
|
95
|
+
default: return '○';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// ── Build file tree ───────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the initial interactive tree — top-level dirs expanded,
|
|
104
|
+
* showing both dirs and files. Returns a flat list.
|
|
105
|
+
*/
|
|
106
|
+
export function buildFileTree(repoPath: string, include?: string[]): FileTreeItem[] {
|
|
107
|
+
const items: FileTreeItem[] = [];
|
|
108
|
+
const entries = readSortedEntries(repoPath);
|
|
109
|
+
|
|
110
|
+
// Build a matcher to determine initial checked state
|
|
111
|
+
const hasInclude = include && include.length > 0;
|
|
112
|
+
const isIncluded = hasInclude ? picomatch(include, { dot: true }) : null;
|
|
113
|
+
// Extract base prefixes for dir-level checks (e.g. 'apps/admin/app' from 'apps/admin/app/**')
|
|
114
|
+
const includeBases = hasInclude
|
|
115
|
+
? include.map(p => picomatch.scan(p).base).filter(b => b && b !== '.')
|
|
116
|
+
: null;
|
|
117
|
+
|
|
118
|
+
/** Check if a relative path (dir or file) should be checked based on include patterns. */
|
|
119
|
+
function shouldCheck(relPath: string, isDir: boolean): boolean {
|
|
120
|
+
if (!hasInclude) return true; // no include filter → check everything
|
|
121
|
+
// For files: match against the include patterns directly
|
|
122
|
+
if (!isDir) return isIncluded!(relPath);
|
|
123
|
+
// For dirs: check if this dir is a prefix of any include base, or vice versa
|
|
124
|
+
if (includeBases) {
|
|
125
|
+
return includeBases.some(base =>
|
|
126
|
+
relPath.startsWith(base) || base.startsWith(relPath),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (entry.isDir) {
|
|
134
|
+
const dirPath = path.join(repoPath, entry.name);
|
|
135
|
+
const stats = scanDirStats(dirPath);
|
|
136
|
+
if (stats.total === 0) continue;
|
|
137
|
+
|
|
138
|
+
const dirChecked = shouldCheck(entry.name, true);
|
|
139
|
+
|
|
140
|
+
// Top-level dir — auto-expanded
|
|
141
|
+
items.push({
|
|
142
|
+
path: entry.name,
|
|
143
|
+
name: entry.name,
|
|
144
|
+
depth: 0,
|
|
145
|
+
isDir: true,
|
|
146
|
+
ext: '',
|
|
147
|
+
checked: dirChecked,
|
|
148
|
+
expanded: true,
|
|
149
|
+
hasChildren: true,
|
|
150
|
+
fileCount: stats.total,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Add children (depth 1)
|
|
154
|
+
const children = readSortedEntries(dirPath);
|
|
155
|
+
for (const child of children) {
|
|
156
|
+
const childRel = `${entry.name}/${child.name}`;
|
|
157
|
+
|
|
158
|
+
if (child.isDir) {
|
|
159
|
+
const childAbs = path.join(dirPath, child.name);
|
|
160
|
+
const cs = scanDirStats(childAbs);
|
|
161
|
+
if (cs.total === 0) continue;
|
|
162
|
+
|
|
163
|
+
items.push({
|
|
164
|
+
path: childRel,
|
|
165
|
+
name: child.name,
|
|
166
|
+
depth: 1,
|
|
167
|
+
isDir: true,
|
|
168
|
+
ext: '',
|
|
169
|
+
checked: shouldCheck(childRel, true),
|
|
170
|
+
expanded: false,
|
|
171
|
+
hasChildren: cs.hasSubdirs || cs.total > 0,
|
|
172
|
+
fileCount: cs.total,
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
const ext = path.extname(child.name).toLowerCase();
|
|
176
|
+
if (!SUPPORTED_EXTENSIONS[ext]) continue;
|
|
177
|
+
|
|
178
|
+
items.push({
|
|
179
|
+
path: childRel,
|
|
180
|
+
name: child.name,
|
|
181
|
+
depth: 1,
|
|
182
|
+
isDir: false,
|
|
183
|
+
ext,
|
|
184
|
+
checked: shouldCheck(childRel, false),
|
|
185
|
+
expanded: false,
|
|
186
|
+
hasChildren: false,
|
|
187
|
+
fileCount: 0,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
// Root-level file
|
|
193
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
194
|
+
if (!SUPPORTED_EXTENSIONS[ext]) continue;
|
|
195
|
+
|
|
196
|
+
items.push({
|
|
197
|
+
path: entry.name,
|
|
198
|
+
name: entry.name,
|
|
199
|
+
depth: 0,
|
|
200
|
+
isDir: false,
|
|
201
|
+
ext,
|
|
202
|
+
checked: shouldCheck(entry.name, false),
|
|
203
|
+
expanded: false,
|
|
204
|
+
hasChildren: false,
|
|
205
|
+
fileCount: 0,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return items;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
/** Expand a directory — insert its children after it. Returns new array. */
|
|
215
|
+
export function expandDir(items: FileTreeItem[], index: number, repoPath: string): FileTreeItem[] {
|
|
216
|
+
const node = items[index];
|
|
217
|
+
if (!node || !node.isDir || node.expanded) return items;
|
|
218
|
+
|
|
219
|
+
const absDir = path.join(repoPath, node.path);
|
|
220
|
+
const entries = readSortedEntries(absDir);
|
|
221
|
+
const children: FileTreeItem[] = [];
|
|
222
|
+
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const childRel = `${node.path}/${entry.name}`;
|
|
225
|
+
|
|
226
|
+
if (entry.isDir) {
|
|
227
|
+
const childAbs = path.join(absDir, entry.name);
|
|
228
|
+
const stats = scanDirStats(childAbs);
|
|
229
|
+
if (stats.total === 0) continue;
|
|
230
|
+
|
|
231
|
+
children.push({
|
|
232
|
+
path: childRel,
|
|
233
|
+
name: entry.name,
|
|
234
|
+
depth: node.depth + 1,
|
|
235
|
+
isDir: true,
|
|
236
|
+
ext: '',
|
|
237
|
+
checked: node.checked,
|
|
238
|
+
expanded: false,
|
|
239
|
+
hasChildren: stats.hasSubdirs || stats.total > 0,
|
|
240
|
+
fileCount: stats.total,
|
|
241
|
+
});
|
|
242
|
+
} else {
|
|
243
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
244
|
+
if (!SUPPORTED_EXTENSIONS[ext]) continue;
|
|
245
|
+
|
|
246
|
+
children.push({
|
|
247
|
+
path: childRel,
|
|
248
|
+
name: entry.name,
|
|
249
|
+
depth: node.depth + 1,
|
|
250
|
+
isDir: false,
|
|
251
|
+
ext,
|
|
252
|
+
checked: node.checked,
|
|
253
|
+
expanded: false,
|
|
254
|
+
hasChildren: false,
|
|
255
|
+
fileCount: 0,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const next = [...items];
|
|
261
|
+
next[index] = { ...node, expanded: true };
|
|
262
|
+
next.splice(index + 1, 0, ...children);
|
|
263
|
+
return next;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
/** Collapse a directory — remove all deeper items after it. Returns new array. */
|
|
268
|
+
export function collapseDir(items: FileTreeItem[], index: number): FileTreeItem[] {
|
|
269
|
+
const node = items[index];
|
|
270
|
+
if (!node || !node.isDir || !node.expanded) return items;
|
|
271
|
+
|
|
272
|
+
let removeCount = 0;
|
|
273
|
+
for (let i = index + 1; i < items.length; i++) {
|
|
274
|
+
if (items[i]!.depth <= node.depth) break;
|
|
275
|
+
removeCount++;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const next = [...items];
|
|
279
|
+
next[index] = { ...node, expanded: false };
|
|
280
|
+
next.splice(index + 1, removeCount);
|
|
281
|
+
return next;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
/** Toggle a directory's checked state, cascading to visible children. */
|
|
286
|
+
export function toggleDir(items: FileTreeItem[], index: number): FileTreeItem[] {
|
|
287
|
+
const node = items[index];
|
|
288
|
+
if (!node || !node.isDir) return items;
|
|
289
|
+
|
|
290
|
+
const newChecked = !node.checked;
|
|
291
|
+
const next = [...items];
|
|
292
|
+
next[index] = { ...node, checked: newChecked };
|
|
293
|
+
|
|
294
|
+
// Cascade DOWN to children only
|
|
295
|
+
for (let i = index + 1; i < next.length; i++) {
|
|
296
|
+
if (next[i]!.depth <= node.depth) break;
|
|
297
|
+
next[i] = { ...next[i]!, checked: newChecked };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return next;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
/** Toggle an individual file's checked state. */
|
|
305
|
+
export function toggleFile(items: FileTreeItem[], index: number): FileTreeItem[] {
|
|
306
|
+
const node = items[index];
|
|
307
|
+
if (!node || node.isDir) return items;
|
|
308
|
+
|
|
309
|
+
const next = [...items];
|
|
310
|
+
next[index] = { ...node, checked: !node.checked };
|
|
311
|
+
return next;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
/** Set all dirs to checked or unchecked. */
|
|
316
|
+
export function setAllDirs(items: FileTreeItem[], checked: boolean): FileTreeItem[] {
|
|
317
|
+
return items.map(item => item.isDir ? { ...item, checked } : { ...item, checked });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
/** Generate include/ignore patterns from tree state. */
|
|
322
|
+
export function generatePatternsFromTree(
|
|
323
|
+
items: FileTreeItem[],
|
|
324
|
+
originalInclude?: string[],
|
|
325
|
+
): { include: string[]; ignore: string[] } {
|
|
326
|
+
const include: string[] = [];
|
|
327
|
+
const ignore: string[] = [];
|
|
328
|
+
|
|
329
|
+
const allDirs = items.filter(i => i.isDir);
|
|
330
|
+
const topDirs = allDirs.filter(i => i.depth === 0);
|
|
331
|
+
|
|
332
|
+
// If everything is checked, no filtering needed
|
|
333
|
+
if (topDirs.every(d => d.checked)) {
|
|
334
|
+
const uncheckedSubs = allDirs.filter(d => !d.checked && d.depth > 0);
|
|
335
|
+
if (uncheckedSubs.length === 0) return { include: [], ignore: [] };
|
|
336
|
+
for (const item of uncheckedSubs) {
|
|
337
|
+
ignore.push(`${item.path}/**`);
|
|
338
|
+
}
|
|
339
|
+
return { include, ignore };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// If nothing is checked, return empty
|
|
343
|
+
if (allDirs.every(d => !d.checked)) {
|
|
344
|
+
return { include: [], ignore: [] };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Build lookup: for each dir path, which original patterns applied to it
|
|
348
|
+
const originalByDir = new Map<string, string[]>();
|
|
349
|
+
if (originalInclude && originalInclude.length > 0) {
|
|
350
|
+
for (const pattern of originalInclude) {
|
|
351
|
+
// Extract the base directory from the pattern
|
|
352
|
+
const base = pattern.replace(/\/\*\*$/, '').replace(/\/\*$/, '');
|
|
353
|
+
// Find which top-level (or depth-1) dir this pattern falls under
|
|
354
|
+
const parts = base.split('/');
|
|
355
|
+
// Map to all ancestor dirs
|
|
356
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
357
|
+
const dirPath = parts.slice(0, i).join('/');
|
|
358
|
+
const existing = originalByDir.get(dirPath) ?? [];
|
|
359
|
+
existing.push(pattern);
|
|
360
|
+
originalByDir.set(dirPath, existing);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Get visible children of a dir in the flat list */
|
|
366
|
+
function getVisibleChildren(parentIdx: number): FileTreeItem[] {
|
|
367
|
+
const parent = items[parentIdx]!;
|
|
368
|
+
const children: FileTreeItem[] = [];
|
|
369
|
+
for (let i = parentIdx + 1; i < items.length; i++) {
|
|
370
|
+
if (items[i]!.depth <= parent.depth) break;
|
|
371
|
+
if (items[i]!.depth === parent.depth + 1) children.push(items[i]!);
|
|
372
|
+
}
|
|
373
|
+
return children;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Check if a dir is a "full inclusion" — all its visible children are checked */
|
|
377
|
+
function isFullInclusion(idx: number): boolean {
|
|
378
|
+
const children = getVisibleChildren(idx);
|
|
379
|
+
if (children.length === 0) return true;
|
|
380
|
+
return children.filter(c => c.isDir).every(c => c.checked);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Walk checked dirs and determine include patterns
|
|
384
|
+
for (let i = 0; i < items.length; i++) {
|
|
385
|
+
const item = items[i]!;
|
|
386
|
+
if (!item.isDir || !item.checked) continue;
|
|
387
|
+
|
|
388
|
+
// Skip if a REAL ancestor is also checked and is a full inclusion
|
|
389
|
+
let coveredByParent = false;
|
|
390
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
391
|
+
const ancestor = items[j]!;
|
|
392
|
+
if (ancestor.isDir && ancestor.depth < item.depth
|
|
393
|
+
&& item.path.startsWith(ancestor.path + '/')
|
|
394
|
+
&& ancestor.checked) {
|
|
395
|
+
if (isFullInclusion(j)) {
|
|
396
|
+
coveredByParent = true;
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (coveredByParent) continue;
|
|
402
|
+
|
|
403
|
+
// Check if original patterns exist for this dir — preserve them
|
|
404
|
+
const origPatterns = originalByDir.get(item.path);
|
|
405
|
+
if (origPatterns && origPatterns.length > 0) {
|
|
406
|
+
// Use original patterns that are scoped to or under this dir
|
|
407
|
+
for (const p of origPatterns) {
|
|
408
|
+
if (!include.includes(p)) {
|
|
409
|
+
include.push(p);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
// New selection — generate fresh pattern
|
|
414
|
+
include.push(`${item.path}/**`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Build a set of included dir prefixes for coverage checks
|
|
419
|
+
const includedPrefixes = new Set(include);
|
|
420
|
+
|
|
421
|
+
// Handle individually checked files whose parent dir is NOT in the include set
|
|
422
|
+
for (const item of items) {
|
|
423
|
+
if (item.isDir || !item.checked) continue;
|
|
424
|
+
// Check if this file is already covered by an included directory
|
|
425
|
+
const covered = [...includedPrefixes].some(p => {
|
|
426
|
+
const base = p.replace(/\/\*\*$/, '').replace(/\/\*$/, '');
|
|
427
|
+
return item.path.startsWith(base + '/') || item.path === base;
|
|
428
|
+
});
|
|
429
|
+
if (!covered) {
|
|
430
|
+
include.push(item.path);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Handle individually unchecked files inside checked directories
|
|
435
|
+
for (const item of items) {
|
|
436
|
+
if (item.isDir || item.checked) continue;
|
|
437
|
+
const parentPath = item.path.split('/').slice(0, -1).join('/');
|
|
438
|
+
const parentIncluded = includedPrefixes.has(`${parentPath}/**`);
|
|
439
|
+
if (parentIncluded) {
|
|
440
|
+
ignore.push(item.path);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { include, ignore };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
/** Count total selected files in the tree. */
|
|
449
|
+
export function countSelectedFiles(items: FileTreeItem[]): number {
|
|
450
|
+
let total = 0;
|
|
451
|
+
for (const item of items) {
|
|
452
|
+
if (!item.isDir && item.checked) total++;
|
|
453
|
+
// Count expanded dirs' direct file count only if not expanded (avoid double count)
|
|
454
|
+
if (item.isDir && item.checked && !item.expanded) total += item.fileCount;
|
|
455
|
+
}
|
|
456
|
+
return total;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
/** Count total files in the tree. */
|
|
461
|
+
export function countTotalFiles(items: FileTreeItem[]): number {
|
|
462
|
+
return items.filter(i => i.depth === 0 && i.isDir).reduce((s, i) => s + i.fileCount, 0)
|
|
463
|
+
+ items.filter(i => i.depth === 0 && !i.isDir).length;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
// ── Internal helpers ──────────────────────────────────
|
|
468
|
+
|
|
469
|
+
interface DirStats {
|
|
470
|
+
total: number;
|
|
471
|
+
byLang: Map<string, number>;
|
|
472
|
+
hasSubdirs: boolean;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function scanDirStats(dirPath: string): DirStats {
|
|
476
|
+
const byLang = new Map<string, number>();
|
|
477
|
+
let total = 0;
|
|
478
|
+
let hasSubdirs = false;
|
|
479
|
+
|
|
480
|
+
function walk(dir: string): void {
|
|
481
|
+
for (const entry of readDirSafe(dir)) {
|
|
482
|
+
if (isDirEntry(dir, entry)) {
|
|
483
|
+
if (isIgnoredDir(entry.name) || entry.name.startsWith('.')) continue;
|
|
484
|
+
hasSubdirs = true;
|
|
485
|
+
walk(path.join(dir, entry.name));
|
|
486
|
+
} else if (entry.isFile()) {
|
|
487
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
488
|
+
const lang = SUPPORTED_EXTENSIONS[ext];
|
|
489
|
+
if (lang) { byLang.set(lang, (byLang.get(lang) ?? 0) + 1); total++; }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
walk(dirPath);
|
|
495
|
+
return { total, byLang, hasSubdirs };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface SortedEntry { name: string; isDir: boolean }
|
|
499
|
+
|
|
500
|
+
function readSortedEntries(dir: string): SortedEntry[] {
|
|
501
|
+
const raw = readDirSafe(dir);
|
|
502
|
+
const entries: SortedEntry[] = [];
|
|
503
|
+
|
|
504
|
+
for (const e of raw) {
|
|
505
|
+
if (e.name.startsWith('.')) continue;
|
|
506
|
+
if (isDirEntry(dir, e)) {
|
|
507
|
+
if (isIgnoredDir(e.name)) continue;
|
|
508
|
+
entries.push({ name: e.name, isDir: true });
|
|
509
|
+
} else if (e.isFile()) {
|
|
510
|
+
entries.push({ name: e.name, isDir: false });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return entries.sort((a, b) => {
|
|
515
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
516
|
+
return a.name.localeCompare(b.name);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function readDirSafe(dir: string): fs.Dirent[] {
|
|
521
|
+
try { return fs.readdirSync(dir, { withFileTypes: true }); }
|
|
522
|
+
catch { return []; }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function isDirEntry(parentDir: string, entry: fs.Dirent): boolean {
|
|
526
|
+
if (entry.isDirectory()) return true;
|
|
527
|
+
if (entry.isSymbolicLink()) {
|
|
528
|
+
try { return fs.statSync(path.join(parentDir, entry.name)).isDirectory(); }
|
|
529
|
+
catch { return false; }
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
// ── Docs & Git preview ────────────────────────────────
|
|
536
|
+
|
|
537
|
+
export interface PreviewLine {
|
|
538
|
+
text: string;
|
|
539
|
+
color?: string;
|
|
540
|
+
bold?: boolean;
|
|
541
|
+
dim?: boolean;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Scan all markdown files and return preview lines. */
|
|
545
|
+
export function scanDocsPreview(repoPath: string): PreviewLine[] {
|
|
546
|
+
const mdFiles: string[] = [];
|
|
547
|
+
|
|
548
|
+
function walk(dir: string, rel: string): void {
|
|
549
|
+
for (const entry of readDirSafe(dir)) {
|
|
550
|
+
if (entry.name.startsWith('.')) continue;
|
|
551
|
+
const fullPath = path.join(dir, entry.name);
|
|
552
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
553
|
+
|
|
554
|
+
if (isDirEntry(dir, entry)) {
|
|
555
|
+
if (isIgnoredDir(entry.name)) continue;
|
|
556
|
+
walk(fullPath, relPath);
|
|
557
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
558
|
+
mdFiles.push(relPath);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
walk(repoPath, '');
|
|
564
|
+
mdFiles.sort();
|
|
565
|
+
|
|
566
|
+
if (mdFiles.length === 0) {
|
|
567
|
+
return [{ text: ' No markdown files found', dim: true }];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const lines: PreviewLine[] = [
|
|
571
|
+
{ text: `📄 ${mdFiles.length} markdown files`, bold: true },
|
|
572
|
+
{ text: '' },
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
// Group by top-level dir
|
|
576
|
+
const groups = new Map<string, string[]>();
|
|
577
|
+
for (const f of mdFiles) {
|
|
578
|
+
const parts = f.split('/');
|
|
579
|
+
const group = parts.length > 1 ? parts[0]! : '(root)';
|
|
580
|
+
const list = groups.get(group) || [];
|
|
581
|
+
list.push(f);
|
|
582
|
+
groups.set(group, list);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
for (const [group, files] of groups) {
|
|
586
|
+
if (group !== '(root)') {
|
|
587
|
+
lines.push({ text: ` ${group}/`, bold: true, color: '#E0AF68' });
|
|
588
|
+
}
|
|
589
|
+
for (const f of files) {
|
|
590
|
+
const name = group === '(root)' ? f : f.slice(group.length + 1);
|
|
591
|
+
lines.push({ text: ` MD ${name}`, color: '#519ABA' });
|
|
592
|
+
}
|
|
593
|
+
lines.push({ text: '' });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return lines;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Scan recent git commits and return preview lines. */
|
|
600
|
+
export function scanGitPreview(repoPath: string): PreviewLine[] {
|
|
601
|
+
const gitDir = path.join(repoPath, '.git');
|
|
602
|
+
if (!fs.existsSync(gitDir)) {
|
|
603
|
+
return [{ text: ' No .git directory found', dim: true }];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
const raw = execSync(
|
|
608
|
+
'git log --oneline --format="%h %ar %s" -n 20',
|
|
609
|
+
{ cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
610
|
+
).trim();
|
|
611
|
+
|
|
612
|
+
const commits = raw.split('\n').filter(Boolean);
|
|
613
|
+
if (commits.length === 0) {
|
|
614
|
+
return [{ text: ' No commits found', dim: true }];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Count total commits
|
|
618
|
+
let totalStr = '';
|
|
619
|
+
try {
|
|
620
|
+
totalStr = execSync('git rev-list --count HEAD',
|
|
621
|
+
{ cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
622
|
+
).trim();
|
|
623
|
+
} catch { /* ignore */ }
|
|
624
|
+
|
|
625
|
+
const lines: PreviewLine[] = [
|
|
626
|
+
{ text: `📜 ${totalStr || '?'} commits`, bold: true },
|
|
627
|
+
{ text: '' },
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
for (const line of commits) {
|
|
631
|
+
const spaceIdx = line.indexOf(' ');
|
|
632
|
+
const hash = line.slice(0, spaceIdx);
|
|
633
|
+
const rest = line.slice(spaceIdx + 1);
|
|
634
|
+
// Split "X ago message" — find second space after time
|
|
635
|
+
const timeMatch = rest.match(/^(.+? ago) (.+)$/);
|
|
636
|
+
if (timeMatch) {
|
|
637
|
+
lines.push({
|
|
638
|
+
text: ` ${hash} ${timeMatch[2]}`,
|
|
639
|
+
color: '#C0CAF5',
|
|
640
|
+
});
|
|
641
|
+
} else {
|
|
642
|
+
lines.push({ text: ` ${hash} ${rest}`, dim: true });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return lines;
|
|
647
|
+
} catch {
|
|
648
|
+
return [{ text: ' Failed to read git log', dim: true }];
|
|
649
|
+
}
|
|
650
|
+
}
|