opencode-codeindex 0.1.0 → 0.1.2
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/dist/commands/index.md +16 -0
- package/package.json +5 -3
- package/src/commands/index.md +11 -5
- package/src/index.ts +130 -0
- package/src/lib/file-reader.ts +73 -0
- package/src/lib/output-formatter.ts +83 -0
- package/src/lib/tree-builder.ts +195 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Index current directory with root file contents
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Index the current working directory and show:
|
|
6
|
+
|
|
7
|
+
1. A tree structure of all files and folders
|
|
8
|
+
2. Contents of files at the root level only
|
|
9
|
+
3. Subdirectories show only their structure (no file contents)
|
|
10
|
+
|
|
11
|
+
Use the tree_indexer tool with:
|
|
12
|
+
|
|
13
|
+
- path: the current working directory (or '.' if not specified)
|
|
14
|
+
- maxFileSize: 102400 (100KB)
|
|
15
|
+
|
|
16
|
+
Show the user the formatted markdown output.
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-codeindex",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "OpenCode plugin that indexes directories with root file contents.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "OpenCode",
|
|
7
7
|
"email": "dev@opencode.ai"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"module": "dist/index.js",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
10
13
|
"exports": {
|
|
11
14
|
".": {
|
|
12
15
|
"types": "./dist/index.d.ts",
|
|
@@ -23,8 +26,7 @@
|
|
|
23
26
|
},
|
|
24
27
|
"files": [
|
|
25
28
|
"dist",
|
|
26
|
-
"src
|
|
27
|
-
"src/commands"
|
|
29
|
+
"src"
|
|
28
30
|
],
|
|
29
31
|
"scripts": {
|
|
30
32
|
"build": "bunx tsc -p tsconfig.build.json",
|
package/src/commands/index.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Index current directory with root file contents
|
|
3
|
-
agent: executor
|
|
4
3
|
---
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
- path: `.`
|
|
8
|
-
- maxFileSize: `102400`
|
|
5
|
+
Index the current working directory and show:
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
1. A tree structure of all files and folders
|
|
8
|
+
2. Contents of files at the root level only
|
|
9
|
+
3. Subdirectories show only their structure (no file contents)
|
|
10
|
+
|
|
11
|
+
Use the tree_indexer tool with:
|
|
12
|
+
|
|
13
|
+
- path: the current working directory (or '.' if not specified)
|
|
14
|
+
- maxFileSize: 102400 (100KB)
|
|
15
|
+
|
|
16
|
+
Show the user the formatted markdown output.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
import { tool } from '@opencode-ai/plugin';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { buildTree } from './lib/tree-builder';
|
|
6
|
+
import { readFileContent } from './lib/file-reader';
|
|
7
|
+
import { formatOutput } from './lib/output-formatter';
|
|
8
|
+
|
|
9
|
+
interface CommandFrontmatter {
|
|
10
|
+
description?: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
subtask?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ParsedCommand {
|
|
17
|
+
name: string;
|
|
18
|
+
frontmatter: CommandFrontmatter;
|
|
19
|
+
template: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface IndexArgs {
|
|
23
|
+
path: string;
|
|
24
|
+
maxFileSize?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseFrontmatter(content: string): { frontmatter: CommandFrontmatter; body: string } {
|
|
28
|
+
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
|
29
|
+
const match = content.match(frontmatterRegex);
|
|
30
|
+
|
|
31
|
+
if (!match) {
|
|
32
|
+
return { frontmatter: {}, body: content.trim() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [, yamlContent, body] = match;
|
|
36
|
+
const frontmatter: CommandFrontmatter = {};
|
|
37
|
+
|
|
38
|
+
for (const line of yamlContent.split('\n')) {
|
|
39
|
+
const colonIndex = line.indexOf(':');
|
|
40
|
+
if (colonIndex === -1) continue;
|
|
41
|
+
|
|
42
|
+
const key = line.slice(0, colonIndex).trim();
|
|
43
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
44
|
+
|
|
45
|
+
if (key === 'description') frontmatter.description = value;
|
|
46
|
+
if (key === 'agent') frontmatter.agent = value;
|
|
47
|
+
if (key === 'model') frontmatter.model = value;
|
|
48
|
+
if (key === 'subtask') frontmatter.subtask = value === 'true';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { frontmatter, body: body.trim() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function loadCommands(): Promise<ParsedCommand[]> {
|
|
55
|
+
const commands: ParsedCommand[] = [];
|
|
56
|
+
const commandDir = path.join(import.meta.dir, 'commands');
|
|
57
|
+
const glob = new Bun.Glob('**/*.md');
|
|
58
|
+
|
|
59
|
+
for await (const file of glob.scan({ cwd: commandDir, absolute: true })) {
|
|
60
|
+
const content = await Bun.file(file).text();
|
|
61
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
62
|
+
|
|
63
|
+
const relativePath = path.relative(commandDir, file);
|
|
64
|
+
const name = relativePath.replace(/\.md$/, '').replace(/\//g, '-');
|
|
65
|
+
|
|
66
|
+
commands.push({
|
|
67
|
+
name,
|
|
68
|
+
frontmatter,
|
|
69
|
+
template: body,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return commands;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function indexDirectory(args: IndexArgs): Promise<string> {
|
|
77
|
+
const targetPath = path.resolve(args.path);
|
|
78
|
+
const stat = await fs.stat(targetPath);
|
|
79
|
+
if (!stat.isDirectory()) {
|
|
80
|
+
throw new Error(`Path is not a directory: ${targetPath}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const tree = await buildTree(targetPath);
|
|
84
|
+
const rootFiles = (tree.children ?? []).filter((node) => node.type === 'file');
|
|
85
|
+
|
|
86
|
+
for (const file of rootFiles) {
|
|
87
|
+
const result = await readFileContent(file.path, args.maxFileSize);
|
|
88
|
+
file.content = result.content;
|
|
89
|
+
if (result.error) file.error = result.error;
|
|
90
|
+
file.size = result.size;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return formatOutput(tree, targetPath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const CodeIndexPlugin: Plugin = async () => {
|
|
97
|
+
const commands = await loadCommands();
|
|
98
|
+
|
|
99
|
+
const treeIndexerTool = tool({
|
|
100
|
+
description: 'Generate a directory tree with root file contents',
|
|
101
|
+
args: {
|
|
102
|
+
path: tool.schema.string().describe('Root path to index'),
|
|
103
|
+
maxFileSize: tool.schema.number().optional().describe('Max file size in bytes'),
|
|
104
|
+
},
|
|
105
|
+
async execute(args) {
|
|
106
|
+
return indexDirectory({
|
|
107
|
+
path: args.path,
|
|
108
|
+
maxFileSize: args.maxFileSize,
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
tool: {
|
|
115
|
+
tree_indexer: treeIndexerTool,
|
|
116
|
+
},
|
|
117
|
+
async config(config) {
|
|
118
|
+
config.command = config.command ?? {};
|
|
119
|
+
for (const cmd of commands) {
|
|
120
|
+
config.command[cmd.name] = {
|
|
121
|
+
template: cmd.template,
|
|
122
|
+
description: cmd.frontmatter.description,
|
|
123
|
+
agent: cmd.frontmatter.agent,
|
|
124
|
+
model: cmd.frontmatter.model,
|
|
125
|
+
subtask: cmd.frontmatter.subtask,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import isBinaryPath from 'is-binary-path';
|
|
3
|
+
|
|
4
|
+
export interface FileReadResult {
|
|
5
|
+
content: string;
|
|
6
|
+
size: number;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_FILE_SIZE = 100 * 1024;
|
|
11
|
+
const PROBE_LENGTH = 8000;
|
|
12
|
+
|
|
13
|
+
function formatSize(bytes: number): string {
|
|
14
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
15
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
16
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readProbe(filePath: string): Promise<Buffer> {
|
|
20
|
+
const handle = await fs.open(filePath, 'r');
|
|
21
|
+
try {
|
|
22
|
+
const buffer = Buffer.alloc(PROBE_LENGTH);
|
|
23
|
+
const { bytesRead } = await handle.read(buffer, 0, PROBE_LENGTH, 0);
|
|
24
|
+
return buffer.subarray(0, bytesRead);
|
|
25
|
+
} finally {
|
|
26
|
+
await handle.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isBinaryBuffer(buffer: Buffer): boolean {
|
|
31
|
+
return buffer.includes(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatError(err: unknown, fallback: string): string {
|
|
35
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
36
|
+
const code = String((err as { code?: string }).code ?? '');
|
|
37
|
+
if (code === 'EACCES' || code === 'EPERM') return 'Permission denied';
|
|
38
|
+
}
|
|
39
|
+
if (err instanceof Error) return err.message;
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readFileContent(
|
|
44
|
+
filePath: string,
|
|
45
|
+
maxFileSize: number = DEFAULT_MAX_FILE_SIZE
|
|
46
|
+
): Promise<FileReadResult> {
|
|
47
|
+
try {
|
|
48
|
+
const stat = await fs.stat(filePath);
|
|
49
|
+
const size = stat.size;
|
|
50
|
+
|
|
51
|
+
if (size > maxFileSize) {
|
|
52
|
+
return {
|
|
53
|
+
size,
|
|
54
|
+
content: `[File too large: ${formatSize(size)}]`,
|
|
55
|
+
error: 'File too large',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const probe = await readProbe(filePath);
|
|
60
|
+
if (isBinaryPath(filePath) || isBinaryBuffer(probe)) {
|
|
61
|
+
return { size, content: '[Binary file]', error: 'Binary file' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
65
|
+
return { size, content };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return {
|
|
68
|
+
size: 0,
|
|
69
|
+
content: `[${formatError(err, 'Read error')}]`,
|
|
70
|
+
error: formatError(err, 'Read error'),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { TreeNode } from './tree-builder';
|
|
3
|
+
|
|
4
|
+
function formatSize(bytes?: number): string {
|
|
5
|
+
if (bytes === undefined) return '';
|
|
6
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
7
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
8
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatLanguage(filename: string): string {
|
|
12
|
+
const ext = path.extname(filename).toLowerCase();
|
|
13
|
+
if (ext === '.md') return 'markdown';
|
|
14
|
+
if (ext === '.json') return 'json';
|
|
15
|
+
if (ext === '.ts') return 'typescript';
|
|
16
|
+
if (ext === '.tsx') return 'tsx';
|
|
17
|
+
if (ext === '.js') return 'javascript';
|
|
18
|
+
if (ext === '.yml' || ext === '.yaml') return 'yaml';
|
|
19
|
+
return 'text';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderTree(root: TreeNode): string[] {
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
const rootLabel = `${path.basename(root.path) || root.path}/`;
|
|
25
|
+
lines.push(rootLabel);
|
|
26
|
+
|
|
27
|
+
const children = root.children ?? [];
|
|
28
|
+
const renderChildren = (nodes: TreeNode[], prefix: string) => {
|
|
29
|
+
nodes.forEach((node, index) => {
|
|
30
|
+
const isLast = index === nodes.length - 1;
|
|
31
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
32
|
+
const name = node.type === 'directory' ? `${node.name}/` : node.name;
|
|
33
|
+
const size = node.type === 'file' ? ` (${formatSize(node.size)})` : '';
|
|
34
|
+
const error = node.error ? ` [${node.error}]` : '';
|
|
35
|
+
lines.push(`${prefix}${connector}${name}${size}${error}`);
|
|
36
|
+
|
|
37
|
+
if (node.type === 'directory' && node.children && node.children.length > 0) {
|
|
38
|
+
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
39
|
+
renderChildren(node.children, nextPrefix);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ordered = [...children].sort((a, b) => {
|
|
45
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
46
|
+
return a.name.localeCompare(b.name);
|
|
47
|
+
});
|
|
48
|
+
renderChildren(ordered, '');
|
|
49
|
+
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatOutput(root: TreeNode, rootPath: string): string {
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
lines.push(`# Directory Index: ${rootPath}`);
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push('## File Structure');
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('```');
|
|
60
|
+
lines.push(...renderTree(root));
|
|
61
|
+
lines.push('```');
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push('## Root Level Files');
|
|
64
|
+
lines.push('');
|
|
65
|
+
|
|
66
|
+
const rootFiles = (root.children ?? []).filter((node) => node.type === 'file');
|
|
67
|
+
if (rootFiles.length === 0) {
|
|
68
|
+
lines.push('_No root-level files found._');
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const file of rootFiles) {
|
|
73
|
+
const content = file.content ?? (file.error ? `[${file.error}]` : '[No content]');
|
|
74
|
+
const language = formatLanguage(file.name);
|
|
75
|
+
lines.push(`### ${file.name}`);
|
|
76
|
+
lines.push('```' + language);
|
|
77
|
+
lines.push(content);
|
|
78
|
+
lines.push('```');
|
|
79
|
+
lines.push('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import type { Dirent } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import ignore, { type Ignore } from 'ignore';
|
|
5
|
+
|
|
6
|
+
export interface TreeNode {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
type: 'file' | 'directory';
|
|
10
|
+
depth: number;
|
|
11
|
+
children?: TreeNode[];
|
|
12
|
+
content?: string;
|
|
13
|
+
size?: number;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TreeBuilderOptions {
|
|
18
|
+
skipNames?: string[];
|
|
19
|
+
skipExtensions?: string[];
|
|
20
|
+
respectGitignore?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SKIP_NAMES = new Set([
|
|
24
|
+
'.git',
|
|
25
|
+
'node_modules',
|
|
26
|
+
'.opencode',
|
|
27
|
+
'dist',
|
|
28
|
+
'build',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const DEFAULT_SKIP_EXTENSIONS = new Set(['.log']);
|
|
32
|
+
|
|
33
|
+
function normalizeGitignorePath(relativePath: string): string {
|
|
34
|
+
return relativePath.split(path.sep).join('/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatError(err: unknown, fallback: string): string {
|
|
38
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
39
|
+
const code = String((err as { code?: string }).code ?? '');
|
|
40
|
+
if (code === 'EACCES' || code === 'EPERM') return 'Permission denied';
|
|
41
|
+
if (code === 'ENOENT') return 'Path not found';
|
|
42
|
+
}
|
|
43
|
+
if (err instanceof Error) return err.message;
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function loadGitignore(rootPath: string, enabled: boolean): Promise<Ignore | null> {
|
|
48
|
+
if (!enabled) return null;
|
|
49
|
+
const gitignorePath = path.join(rootPath, '.gitignore');
|
|
50
|
+
try {
|
|
51
|
+
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
52
|
+
return ignore().add(content);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shouldSkip(
|
|
59
|
+
name: string,
|
|
60
|
+
skipNames: Set<string>,
|
|
61
|
+
skipExtensions: Set<string>
|
|
62
|
+
): boolean {
|
|
63
|
+
if (skipNames.has(name)) return true;
|
|
64
|
+
for (const ext of skipExtensions) {
|
|
65
|
+
if (name.endsWith(ext)) return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function walkDirectory(
|
|
71
|
+
absolutePath: string,
|
|
72
|
+
relativePath: string,
|
|
73
|
+
depth: number,
|
|
74
|
+
ignoreRules: Ignore | null,
|
|
75
|
+
skipNames: Set<string>,
|
|
76
|
+
skipExtensions: Set<string>,
|
|
77
|
+
visited: Set<string>
|
|
78
|
+
): Promise<TreeNode[]> {
|
|
79
|
+
let entries: Dirent[] = [];
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
name: path.basename(absolutePath),
|
|
86
|
+
path: absolutePath,
|
|
87
|
+
type: 'directory',
|
|
88
|
+
depth,
|
|
89
|
+
error: formatError(err, 'Read error'),
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const nodes: TreeNode[] = [];
|
|
95
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
96
|
+
if (shouldSkip(entry.name, skipNames, skipExtensions)) continue;
|
|
97
|
+
|
|
98
|
+
const entryPath = path.join(absolutePath, entry.name);
|
|
99
|
+
const entryRelative = normalizeGitignorePath(path.join(relativePath, entry.name));
|
|
100
|
+
if (ignoreRules?.ignores(entryRelative)) continue;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const stat = await fs.lstat(entryPath);
|
|
104
|
+
|
|
105
|
+
if (stat.isSymbolicLink()) {
|
|
106
|
+
const resolved = await fs.realpath(entryPath);
|
|
107
|
+
const resolvedStat = await fs.stat(entryPath);
|
|
108
|
+
if (resolvedStat.isDirectory()) {
|
|
109
|
+
if (visited.has(resolved)) {
|
|
110
|
+
nodes.push({
|
|
111
|
+
name: entry.name,
|
|
112
|
+
path: entryPath,
|
|
113
|
+
type: 'directory',
|
|
114
|
+
depth,
|
|
115
|
+
error: 'Circular reference',
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
visited.add(resolved);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (stat.isDirectory()) {
|
|
124
|
+
const children = await walkDirectory(
|
|
125
|
+
entryPath,
|
|
126
|
+
entryRelative,
|
|
127
|
+
depth + 1,
|
|
128
|
+
ignoreRules,
|
|
129
|
+
skipNames,
|
|
130
|
+
skipExtensions,
|
|
131
|
+
visited
|
|
132
|
+
);
|
|
133
|
+
nodes.push({
|
|
134
|
+
name: entry.name,
|
|
135
|
+
path: entryPath,
|
|
136
|
+
type: 'directory',
|
|
137
|
+
depth,
|
|
138
|
+
children,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
nodes.push({
|
|
142
|
+
name: entry.name,
|
|
143
|
+
path: entryPath,
|
|
144
|
+
type: 'file',
|
|
145
|
+
depth,
|
|
146
|
+
size: stat.size,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
nodes.push({
|
|
151
|
+
name: entry.name,
|
|
152
|
+
path: entryPath,
|
|
153
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
154
|
+
depth,
|
|
155
|
+
error: formatError(err, 'Read error'),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return nodes;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function buildTree(
|
|
164
|
+
rootPath: string,
|
|
165
|
+
options: TreeBuilderOptions = {}
|
|
166
|
+
): Promise<TreeNode> {
|
|
167
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
168
|
+
const stat = await fs.stat(resolvedRoot);
|
|
169
|
+
if (!stat.isDirectory()) {
|
|
170
|
+
throw new Error(`Path is not a directory: ${resolvedRoot}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const ignoreRules = await loadGitignore(resolvedRoot, options.respectGitignore !== false);
|
|
174
|
+
const skipNames = new Set(options.skipNames ?? Array.from(DEFAULT_SKIP_NAMES));
|
|
175
|
+
const skipExtensions = new Set(options.skipExtensions ?? Array.from(DEFAULT_SKIP_EXTENSIONS));
|
|
176
|
+
const visited = new Set<string>([resolvedRoot]);
|
|
177
|
+
|
|
178
|
+
const children = await walkDirectory(
|
|
179
|
+
resolvedRoot,
|
|
180
|
+
'',
|
|
181
|
+
0,
|
|
182
|
+
ignoreRules,
|
|
183
|
+
skipNames,
|
|
184
|
+
skipExtensions,
|
|
185
|
+
visited
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
name: path.basename(resolvedRoot) || resolvedRoot,
|
|
190
|
+
path: resolvedRoot,
|
|
191
|
+
type: 'directory',
|
|
192
|
+
depth: -1,
|
|
193
|
+
children,
|
|
194
|
+
};
|
|
195
|
+
}
|