diff --git a/package-lock.json b/package-lock.json index eaeed91..9838ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "node-fetch": "^3.3.2" }, "devDependencies": { + "@types/node": "^24.9.1", + "@types/vscode": "^1.105.0", "copyfiles": "^2.4.1", "typescript": "^4.0.0", "vscode": "^1.1.36", @@ -132,15 +134,22 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.16.0" } }, + "node_modules/@types/vscode": { + "version": "1.105.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.105.0.tgz", + "integrity": "sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw==", + "dev": true, + "license": "MIT" + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -2302,9 +2311,9 @@ } }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 9641eb2..81ad241 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "postinstall": "node ./node_modules/vscode/bin/install" }, "devDependencies": { + "@types/node": "^24.9.1", + "@types/vscode": "^1.105.0", "copyfiles": "^2.4.1", "typescript": "^4.0.0", "vscode": "^1.1.36", diff --git a/src/extension.ts b/src/extension.ts index aa64d3e..9740cb6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,10 +1,11 @@ import * as vscode from 'vscode'; import { GitExtension, Repository } from './git'; import { getDiffData, openDifftool } from './utils/gitUtils'; -import { getCodeRefinements, suggestComment, ProjectDetails, generateFileStructure } from './utils/geminiUtils'; +import { getCodeRefinements, suggestComment, ProjectDetails, generateFileStructure, generateDirectoryOverviews } from './utils/geminiUtils'; import { updateFilesInWorkspace, executeCommand, makeFileStructure } from './utils/terminalUtils'; import { fetchAllRepos } from './utils/githubUtils'; import { techStackData } from './assets/techStackData'; +import { updateReadmes } from './utils/readmeUtils'; async function initializeProject() { const name = await vscode.window.showInputBox({ @@ -147,6 +148,15 @@ async function onFilesStaged() { const shouldIncludeDescription = selection === hauntWithDescButton; await updateFilesInWorkspace(refinedCode, shouldIncludeDescription); + // --- generate directory READMEs --- + progress.report({ message: "Generating directory README overviews..." }); + try { + const dirOverviews = await generateDirectoryOverviews(refinedCode); + await updateReadmes(dirOverviews); + } catch (e: any) { + console.warn('Failed to generate directory overviews:', e?.message || e); + } + // --- openDiffTool --- progress.report({ message: "Opening difftool for your review..." }); await openDifftool(); diff --git a/src/utils/geminiUtils.ts b/src/utils/geminiUtils.ts index 74102bf..ccfd0a4 100644 --- a/src/utils/geminiUtils.ts +++ b/src/utils/geminiUtils.ts @@ -210,4 +210,61 @@ async function conventionalCommitPrompt(files: GitDiffData[]): Promise { const finalPrompt = promptTemplate.replace('{{diffDataAsJson}}', diffAsJson); return finalPrompt; +} + +export async function generateDirectoryOverviews(refinedFiles: RefinedCode[]): Promise> { + const apiKey = key(); + const geminiUrl = url(); + + if (!Array.isArray(refinedFiles) || refinedFiles.length === 0) { + return {}; + } + + // Group files by directory + const byDir: Record }> = {}; + for (const f of refinedFiles) { + if (!f || typeof f.name !== 'string') continue; + const dir = path.posix.dirname(f.name).replace(/^\.$/, '.'); + if (!byDir[dir]) byDir[dir] = { files: [] }; + byDir[dir].files.push({ name: path.posix.basename(f.name), desc: f.desc }); + } + + const promptTemplate = await getCustomPrompt('generateDirectoryOverview'); + const directoriesJson = JSON.stringify(byDir, null, 2); + const finalPrompt = promptTemplate.replace('{{directoriesJson}}', directoriesJson); + + const response = await fetch(geminiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-goog-api-key': apiKey, + }, + body: JSON.stringify({ + contents: [{ parts: [{ text: finalPrompt }] }], + generationConfig: { + responseMimeType: 'application/json', + }, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Gemini API request failed with status ${response.status}: ${errorBody}`); + } + + const data = await response.json() as any; + const responseText = data?.candidates?.[0]?.content?.parts?.[0]?.text; + if (!responseText) { + throw new Error('No valid content found in Gemini API response.'); + } + + const errors: any[] = []; + const parsed = parse(responseText, errors) as Record | undefined; + if (errors.length > 0) { + console.warn('JSONC parser encountered recoverable errors while parsing directory overviews:', errors); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Failed to parse directory overview JSON from API response.'); + } + return parsed; } \ No newline at end of file diff --git a/src/utils/promptUtils.ts b/src/utils/promptUtils.ts index 7f488e7..b58114c 100644 --- a/src/utils/promptUtils.ts +++ b/src/utils/promptUtils.ts @@ -5,6 +5,7 @@ interface CustomPrompts { suggestComment?: string; getCodeRefinements?: string; generateFileStructure?: string; + generateDirectoryOverview?: string; } const defaultPrompts = { @@ -103,6 +104,21 @@ Generate a JSON object representing the project's file structure. 1. **JSON ONLY:** Your entire response must be a single, raw JSON object. 2. **NO MARKDOWN:** Do not wrap the JSON in markdown code blocks like \`\`\`json. 3. **NO EXTRA TEXT:** Do not include ANY text, headers, footers, explanations, or conversational filler before or after the JSON object. Your response must start with { and end with }. +` + , + generateDirectoryOverview: ` +You are an expert technical writer assisting in maintaining internal project documentation. + +Task: Given a list of directories with their files and each file's high-level description, produce a concise overview for every directory that accurately reflects its purpose and contents. + +Output Requirements: +- Respond with a single JSON object mapping directory paths to a short paragraph summary string. +- Keep each summary under 120 words. +- Be specific but succinct; mention notable files, responsibilities, and how the directory fits in the project when inferable from the file descriptions. +- Do NOT include code fences or any text other than the raw JSON object. + +Input Data: +{{directoriesJson}} ` }; diff --git a/src/utils/readmeUtils.ts b/src/utils/readmeUtils.ts new file mode 100644 index 0000000..b14d46d --- /dev/null +++ b/src/utils/readmeUtils.ts @@ -0,0 +1,85 @@ +import * as vscode from 'vscode'; + +const AUTO_HEADER = '## Directory Overview (Auto-generated)'; + +function buildAutoSection(summary: string): string { + return `${AUTO_HEADER}\n\n${summary.trim()}\n`; +} + +async function readFileIfExists(uri: vscode.Uri): Promise { + try { + const raw = await vscode.workspace.fs.readFile(uri); + return Buffer.from(raw).toString('utf8'); + } catch { + return undefined; + } +} + +function upsertAutoSection(existing: string | undefined, summary: string): string { + const autoSection = buildAutoSection(summary); + + if (!existing || existing.trim().length === 0) { + // Create a minimal README with just the auto section under a basic title + return `# README\n\n${autoSection}`; + } + + const headerRegex = /^##\s+Directory Overview \(Auto-generated\)\s*$/gim; + const nextHeaderRegex = /^##\s+/gim; + + const match = headerRegex.exec(existing); + if (match && typeof match.index === 'number') { + const startOfHeader = match.index; + const endOfHeader = startOfHeader + match[0].length; + + // Find the next level-2 header after the auto header to know replacement bounds + nextHeaderRegex.lastIndex = endOfHeader; + const nextHeaderMatch = nextHeaderRegex.exec(existing); + const replaceEnd = nextHeaderMatch ? nextHeaderMatch.index : existing.length; + + const before = existing.slice(0, startOfHeader); + const after = existing.slice(replaceEnd); + const needsNewlineBefore = !before.endsWith('\n\n') ? (before.endsWith('\n') ? '\n' : '\n\n') : ''; + const needsNewlineAfter = after.startsWith('\n') ? '' : '\n'; + return `${before}${needsNewlineBefore}${autoSection}${needsNewlineAfter}${after}`; + } + + // If no existing auto section, append one at the end, ensuring spacing + const needsNewline = existing.endsWith('\n') ? '' : '\n'; + const needsDouble = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n'); + return `${existing}${needsDouble}${autoSection}${needsNewline}`; +} + +export async function upsertReadmeForDirectory(dirRelativePath: string, summary: string): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('No open project folder found.'); + } + const rootUri = workspaceFolder.uri; + + const dirUri = dirRelativePath === '.' + ? rootUri + : vscode.Uri.joinPath(rootUri, dirRelativePath); + const readmeUri = vscode.Uri.joinPath(dirUri, 'README.md'); + + // Ensure directory exists before writing + await vscode.workspace.fs.createDirectory(dirUri); + + const existing = await readFileIfExists(readmeUri); + const updated = upsertAutoSection(existing, summary); + const bytes = Buffer.from(updated, 'utf8'); + await vscode.workspace.fs.writeFile(readmeUri, bytes); +} + +export async function updateReadmes(summaries: Record): Promise { + for (const [dir, summary] of Object.entries(summaries)) { + try { + await upsertReadmeForDirectory(dir || '.', summary); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`Failed to update README for ${dir}:`, message); + // Continue with other directories + } + } +} + +export const README_AUTO_HEADER = AUTO_HEADER; diff --git a/tsconfig.json b/tsconfig.json index 7bd736c..5d92ac6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "lib": ["esnext"], + "lib": ["esnext", "dom"], "types": ["node", "vscode"] }, "include": ["src/**/*.ts"],