* FILE READ MODE: resolve `fileArg` (path or basename) to an indexed file and * read it like the Read tool — its current on-disk source with line numbers, * narrowable with `offset`/`limit` exactly as Read's are — preceded by a * one-line blast-radius header (which files depend on it). `sym
(
cg: CodeGraph,
fileArg: string,
opts: { offset?: number; limit?: number; symbolsOnly?: boolean } = {},
)
| 4032 | * through validatePathWithinRoot (#527). |
| 4033 | */ |
| 4034 | private async handleFileView( |
| 4035 | cg: CodeGraph, |
| 4036 | fileArg: string, |
| 4037 | opts: { offset?: number; limit?: number; symbolsOnly?: boolean } = {}, |
| 4038 | ): Promise<ToolResult> { |
| 4039 | const normalize = (p: string) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, ''); |
| 4040 | const wantLower = normalize(fileArg).toLowerCase(); |
| 4041 | const allFiles = cg.getFiles(); |
| 4042 | if (allFiles.length === 0) return this.textResult('No files indexed. Run `codegraph index` first.'); |
| 4043 | |
| 4044 | let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower); |
| 4045 | let candidates: typeof allFiles = []; |
| 4046 | if (!resolved) { |
| 4047 | candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower)); |
| 4048 | if (candidates.length === 1) resolved = candidates[0]; |
| 4049 | } |
| 4050 | if (!resolved && candidates.length === 0) { |
| 4051 | candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower)); |
| 4052 | if (candidates.length === 1) resolved = candidates[0]; |
| 4053 | } |
| 4054 | if (!resolved && candidates.length > 1) { |
| 4055 | return this.textResult( |
| 4056 | [`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '', |
| 4057 | ...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'), |
| 4058 | ); |
| 4059 | } |
| 4060 | if (!resolved) { |
| 4061 | return this.textResult( |
| 4062 | `No indexed file matches "${fileArg}". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly.`, |
| 4063 | ); |
| 4064 | } |
| 4065 | |
| 4066 | const filePath = resolved.path; |
| 4067 | const nodes = cg.getNodesInFile(filePath) |
| 4068 | .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export') |
| 4069 | .sort((a, b) => a.startLine - b.startLine); |
| 4070 | const dependents = cg.getFileDependents(filePath); |
| 4071 | |
| 4072 | // Compact, one-line blast radius (codegraph's value-add over a plain Read). |
| 4073 | const depSummary = dependents.length |
| 4074 | ? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}` |
| 4075 | : 'no other indexed file depends on it'; |
| 4076 | |
| 4077 | // Symbol-map renderer — for symbolsOnly, the config fallback, and read errors. |
| 4078 | const symbolMap = (heading: string, limit = 200): string[] => { |
| 4079 | const lines: string[] = [heading]; |
| 4080 | for (const n of nodes.slice(0, limit)) { |
| 4081 | const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : ''; |
| 4082 | lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`); |
| 4083 | } |
| 4084 | if (nodes.length > limit) lines.push(`- … +${nodes.length - limit} more`); |
| 4085 | return lines; |
| 4086 | }; |
| 4087 | |
| 4088 | // symbolsOnly → the cheap structural overview, no source. |
| 4089 | if (opts.symbolsOnly) { |
| 4090 | const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, '']; |
| 4091 | if (nodes.length) out.push(...symbolMap('**Symbols**')); |
no test coverage detected