diff --git a/README.md b/README.md index 84c41c2..17e77cb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Code Explainer is a VS Code extension scaffold for branch-aware code comprehensi - Compare the current branch with `main` - Explain the current selection with surrounding context - Trace relationships between files, symbols, tests, and configuration +- Generate a reviewable GitHub PR title and description for the current branch ## Development diff --git a/package.json b/package.json index e4ba967..8492373 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "onCommand:codeExplainer.explainRepo", "onCommand:codeExplainer.explainSelection", "onCommand:codeExplainer.compareBranch", + "onCommand:codeExplainer.generatePrDescription", "onCommand:codeExplainer.traceRelationships", "onCommand:codeExplainer.drawFlowChart", "onView:codeExplainer.sidebar" @@ -38,6 +39,10 @@ "command": "codeExplainer.traceRelationships", "title": "Code Explainer: Trace Relationships" }, + { + "command": "codeExplainer.generatePrDescription", + "title": "Code Explainer: Generate PR Description" + }, { "command": "codeExplainer.drawFlowChart", "title": "Code Explainer: Draw Current Branch Diagram" @@ -77,12 +82,20 @@ { "command": "codeExplainer.drawFlowChart", "group": "navigation@102" + }, + { + "command": "codeExplainer.generatePrDescription", + "group": "navigation@103" } ], "explorer/context": [ { "command": "codeExplainer.explainRepo", "group": "navigation@100" + }, + { + "command": "codeExplainer.generatePrDescription", + "group": "navigation@101" } ] }, @@ -114,6 +127,27 @@ "default": true, "description": "Render custom flow-chart visualizations in the explanation panel when available." }, + "codeExplainer.prDescription.defaultStyle": { + "type": "string", + "enum": [ + "business-stakeholder", + "code-collaborator", + "manager", + "other" + ], + "default": "manager", + "description": "Default audience/style for generated PR descriptions." + }, + "codeExplainer.prDescription.defaultGuidelines": { + "type": "string", + "default": "", + "description": "Optional default team guidance or house rules to apply when generating PR descriptions." + }, + "codeExplainer.prDescription.defaultTemplate": { + "type": "string", + "default": "", + "description": "Optional default markdown template or preferred section structure for generated PR descriptions." + }, "codeExplainer.openai.apiKey": { "type": "string", "default": "", diff --git a/src/commands/compareBranch.ts b/src/commands/compareBranch.ts index 2c584e6..47abfc3 100644 --- a/src/commands/compareBranch.ts +++ b/src/commands/compareBranch.ts @@ -39,7 +39,7 @@ export function createCompareBranchCommand( getFresh: () => analysisService.analyze(), render: (result, refresh) => panel.show(result, { - onAction: (action) => void handlePanelAction(action), + onAction: (action) => void handlePanelAction(action, panel), onFileRef: (fileRef) => void openFileRef(fileRef), onRefresh: refresh, }), diff --git a/src/commands/compareFileWithBranch.ts b/src/commands/compareFileWithBranch.ts new file mode 100644 index 0000000..6794f1d --- /dev/null +++ b/src/commands/compareFileWithBranch.ts @@ -0,0 +1,47 @@ +import * as path from "path"; +import * as vscode from "vscode"; +import { BranchAnalysisService } from "../services/analysis/BranchAnalysisService"; +import { GitService } from "../services/git/GitService"; +import { CacheService } from "../storage/CacheService"; +import { CodeExplainerProvider } from "../ui/sidebar/CodeExplainerProvider"; +import { ResultsPanel } from "../ui/webview/panel"; +import { handlePanelAction, openFileRef } from "./shared"; + +export function createCompareFileWithBranchCommand( + panel: ResultsPanel, + analysisService: BranchAnalysisService, + cache: CacheService, + sidebarProvider: CodeExplainerProvider +) { + return async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error("Open a file before comparing it with the base branch."); + } + + const filePath = editor.document.uri.fsPath; + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + if (!workspaceRoot) { + throw new Error("Open a workspace folder before comparing."); + } + + const config = vscode.workspace.getConfiguration("codeExplainer"); + const baseBranch = config.get("baseBranch", "main"); + const git = new GitService(workspaceRoot); + + if (!(await git.isGitRepo())) { + throw new Error("The current workspace is not a git repository."); + } + + // Get the changes for this specific file + const result = await analysisService.analyzeFile(filePath, baseBranch); + const fileName = path.basename(filePath); + + panel.show(result, { + onAction: (action) => void handlePanelAction(action, panel), + onFileRef: (fileRef) => void openFileRef(fileRef), + onRefresh: () => void createCompareFileWithBranchCommand(panel, analysisService, cache, sidebarProvider)(), + }); + }; +} diff --git a/src/commands/drawFlowChart.ts b/src/commands/drawFlowChart.ts index 780e34c..76c4f34 100644 --- a/src/commands/drawFlowChart.ts +++ b/src/commands/drawFlowChart.ts @@ -40,7 +40,7 @@ export function createDrawFlowChartCommand( getFresh: () => analysisService.analyze(), render: (result, refresh) => panel.show(result, { - onAction: (action) => void handlePanelAction(action), + onAction: (action) => void handlePanelAction(action, panel), onFileRef: (fileRef) => void openFileRef(fileRef), onRefresh: refresh, }), diff --git a/src/commands/explainRepo.ts b/src/commands/explainRepo.ts index 2040f3e..c17ad0a 100644 --- a/src/commands/explainRepo.ts +++ b/src/commands/explainRepo.ts @@ -26,7 +26,7 @@ export function createExplainRepoCommand( getFresh: () => analysisService.analyze(), render: (result, refresh) => panel.show(result, { - onAction: (action) => void handlePanelAction(action), + onAction: (action) => void handlePanelAction(action, panel), onFileRef: (fileRef) => void openFileRef(fileRef), onRefresh: refresh, }), diff --git a/src/commands/explainSelection.ts b/src/commands/explainSelection.ts index 4099899..1df4a40 100644 --- a/src/commands/explainSelection.ts +++ b/src/commands/explainSelection.ts @@ -40,7 +40,7 @@ export function createExplainSelectionCommand( getFresh: () => analysisService.explainSelection({ filePath, startLine, endLine }), render: (result, refresh) => panel.show(result, { - onAction: (action) => void handlePanelAction(action), + onAction: (action) => void handlePanelAction(action, panel), onFileRef: (fileRef) => void openFileRef(fileRef), onRefresh: refresh, }), diff --git a/src/commands/generatePrDescription.ts b/src/commands/generatePrDescription.ts new file mode 100644 index 0000000..16b00f4 --- /dev/null +++ b/src/commands/generatePrDescription.ts @@ -0,0 +1,106 @@ +import * as vscode from "vscode"; +import { PrDescriptionExplanation, PrDescriptionStyle } from "../models/types"; +import { PrDescriptionAnalysisService } from "../services/analysis/PrDescriptionAnalysisService"; +import { ResultsPanel } from "../ui/webview/panel"; +import { openFileRef } from "./shared"; + +type DraftPanelMessage = + | { + type: "prRegenerate"; + title: string; + body: string; + style: PrDescriptionStyle; + customInstructions: string; + } + | { + type: "prApply"; + title: string; + body: string; + style: PrDescriptionStyle; + customInstructions: string; + }; + +export function createGeneratePrDescriptionCommand( + panel: ResultsPanel, + analysisService: PrDescriptionAnalysisService +) { + return async () => { + const renderResult = (result: PrDescriptionExplanation) => { + panel.show(result, { + onAction: () => undefined, + onFileRef: (fileRef) => void openFileRef(fileRef), + onRefresh: () => { + void runAnalysis(); + }, + onMessage: (message) => { + void handlePanelMessage(message as DraftPanelMessage, result, renderResult); + }, + }); + }; + + const runAnalysis = async (options?: { style?: PrDescriptionStyle; customInstructions?: string }) => { + panel.showLoading("Generate PR Description", "Reviewing the current branch and preparing a PR description draft."); + const result = await analysisService.analyze(options); + renderResult(result); + }; + + const handlePanelMessage = async ( + message: DraftPanelMessage, + currentResult: PrDescriptionExplanation, + render: (result: PrDescriptionExplanation) => void + ) => { + if (message.type === "prRegenerate") { + await runAnalysis({ + style: message.style, + customInstructions: message.customInstructions, + }); + return; + } + + if (message.type !== "prApply") { + return; + } + + panel.showLoading("Generate PR Description", "Applying the reviewed draft to GitHub."); + const applied = await analysisService.applyDraft({ + draft: currentResult, + title: message.title, + body: message.body, + }); + + if (applied.status === "cancelled") { + render({ + ...currentResult, + draftTitle: message.title, + draftBody: message.body, + style: message.style, + customInstructions: message.customInstructions, + }); + return; + } + + const updatedResult = analysisService.withAppliedDraft( + { + ...currentResult, + style: message.style, + customInstructions: message.customInstructions, + }, + message.title, + message.body, + applied.pullRequest + ); + render(updatedResult); + + const messageText = applied.status === "created" + ? `Created pull request #${applied.pullRequest?.number}.` + : `Updated pull request #${applied.pullRequest?.number}.`; + const openAction = "Open PR"; + const selection = await vscode.window.showInformationMessage(messageText, openAction); + if (selection === openAction && applied.pullRequest?.url) { + await vscode.env.openExternal(vscode.Uri.parse(applied.pullRequest.url)); + } + }; + + await runAnalysis(); + }; +} diff --git a/src/commands/shared.ts b/src/commands/shared.ts index b8d419b..5198520 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -5,10 +5,16 @@ import { CacheService } from "../storage/CacheService"; import { CodeExplainerProvider } from "../ui/sidebar/CodeExplainerProvider"; import { ResultsPanel } from "../ui/webview/panel"; -export async function handlePanelAction(action: string): Promise { +export async function handlePanelAction(action: string, panel?: ResultsPanel): Promise { const normalized = action.toLowerCase(); if (normalized.includes("branch")) { + // If we're in a selection context, compare only that file + const currentResult = panel?.getCurrentResult(); + if (currentResult?.kind === "selection") { + await vscode.commands.executeCommand("codeExplainer.compareFileWithBranch"); + return; + } await vscode.commands.executeCommand("codeExplainer.compareBranch"); return; } @@ -33,6 +39,11 @@ export async function handlePanelAction(action: string): Promise { return; } + if (normalized.includes("pr description")) { + await vscode.commands.executeCommand("codeExplainer.generatePrDescription"); + return; + } + await vscode.commands.executeCommand("codeExplainer.explainRepo"); } diff --git a/src/commands/traceRelationships.ts b/src/commands/traceRelationships.ts index d224355..5b301c5 100644 --- a/src/commands/traceRelationships.ts +++ b/src/commands/traceRelationships.ts @@ -40,7 +40,7 @@ export function createTraceRelationshipsCommand( getFresh: () => analysisService.traceRelationships({ filePath, startLine, endLine }), render: (result, refresh) => panel.show(result, { - onAction: (action) => void handlePanelAction(action), + onAction: (action) => void handlePanelAction(action, panel), onFileRef: (fileRef) => void openFileRef(fileRef), onRefresh: refresh, }), diff --git a/src/extension.ts b/src/extension.ts index 9d8073d..daffde1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,15 +1,19 @@ import * as vscode from "vscode"; import { createCompareBranchCommand } from "./commands/compareBranch"; +import { createCompareFileWithBranchCommand } from "./commands/compareFileWithBranch"; import { createDrawFlowChartCommand } from "./commands/drawFlowChart"; import { createExplainRepoCommand } from "./commands/explainRepo"; import { createExplainSelectionCommand } from "./commands/explainSelection"; +import { createGeneratePrDescriptionCommand } from "./commands/generatePrDescription"; import { handlePanelAction, openFileRef } from "./commands/shared"; import { createTraceRelationshipsCommand } from "./commands/traceRelationships"; import { CachedResultEntry } from "./models/types"; import { BranchAnalysisService } from "./services/analysis/BranchAnalysisService"; import { FlowAnalysisService } from "./services/analysis/FlowAnalysisService"; +import { PrDescriptionAnalysisService } from "./services/analysis/PrDescriptionAnalysisService"; import { RepoAnalysisService } from "./services/analysis/RepoAnalysisService"; import { SelectionAnalysisService } from "./services/analysis/SelectionAnalysisService"; +import { GitHubService } from "./services/github/GitHubService"; import { PromptBuilder } from "./services/llm/PromptBuilder"; import { RelationshipService } from "./services/repo/RelationshipService"; import { RepoScanner } from "./services/repo/RepoScanner"; @@ -22,10 +26,12 @@ export function activate(context: vscode.ExtensionContext): void { const repoScanner = new RepoScanner(); const symbolService = new SymbolService(); const relationshipService = new RelationshipService(symbolService); + const githubService = new GitHubService(); const promptBuilder = new PromptBuilder(); const repoAnalysis = new RepoAnalysisService(repoScanner, promptBuilder); const branchAnalysis = new BranchAnalysisService(repoScanner, promptBuilder); const flowAnalysis = new FlowAnalysisService(repoScanner, promptBuilder); + const prDescriptionAnalysis = new PrDescriptionAnalysisService(repoScanner, promptBuilder, githubService); const selectionAnalysis = new SelectionAnalysisService( repoScanner, symbolService, @@ -38,7 +44,9 @@ export function activate(context: vscode.ExtensionContext): void { const explainRepo = createExplainRepoCommand(panel, repoAnalysis, cacheService, sidebarProvider); const explainSelection = createExplainSelectionCommand(panel, selectionAnalysis, cacheService, sidebarProvider); const compareBranch = createCompareBranchCommand(panel, branchAnalysis, cacheService, sidebarProvider); + const compareFileWithBranch = createCompareFileWithBranchCommand(panel, branchAnalysis, cacheService, sidebarProvider); const drawFlowChart = createDrawFlowChartCommand(panel, flowAnalysis, cacheService, sidebarProvider); + const generatePrDescription = createGeneratePrDescriptionCommand(panel, prDescriptionAnalysis); const traceRelationships = createTraceRelationshipsCommand( panel, selectionAnalysis, @@ -52,7 +60,9 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand("codeExplainer.explainRepo", wrapCommand(explainRepo)), vscode.commands.registerCommand("codeExplainer.explainSelection", wrapCommand(explainSelection)), vscode.commands.registerCommand("codeExplainer.compareBranch", wrapCommand(compareBranch)), + vscode.commands.registerCommand("codeExplainer.compareFileWithBranch", wrapCommand(compareFileWithBranch)), vscode.commands.registerCommand("codeExplainer.drawFlowChart", wrapCommand(drawFlowChart)), + vscode.commands.registerCommand("codeExplainer.generatePrDescription", wrapCommand(generatePrDescription)), vscode.commands.registerCommand("codeExplainer.traceRelationships", wrapCommand(traceRelationships)), vscode.commands.registerCommand( "codeExplainer.openCachedResult", @@ -63,7 +73,7 @@ export function activate(context: vscode.ExtensionContext): void { } panel.show(entry.result, { - onAction: (action) => void handlePanelAction(action), + onAction: (action) => void handlePanelAction(action, panel), onFileRef: (fileRef) => void openFileRef(fileRef), onRefresh: () => void refreshCachedEntry( diff --git a/src/models/types.ts b/src/models/types.ts index 3d5669f..2d208ae 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -1,4 +1,6 @@ export type ProviderName = "openai" | "anthropic"; +export type PrDescriptionStyle = "business-stakeholder" | "code-collaborator" | "manager" | "other"; +export type PrState = "no-pr" | "existing-empty" | "existing-with-description"; export type FileRef = { path: string; @@ -90,12 +92,31 @@ export type FlowExplanation = { flowChart: FlowChart; }; +export type PrDescriptionExplanation = { + kind: "prDescription"; + headline: string; + cards: ExplanationCard[]; + draftTitle: string; + draftBody: string; + style: PrDescriptionStyle; + customInstructions: string; + branchName: string; + baseBranch: string; + prState: PrState; + hasRemoteBranch: boolean; + existingPrNumber?: number; + existingPrUrl?: string; + defaultGuidelines?: string; + defaultTemplate?: string; +}; + export type AnalysisResult = | RepoSummary | BranchSummary | SelectionExplanation | TraceExplanation - | FlowExplanation; + | FlowExplanation + | PrDescriptionExplanation; export type RepoContext = { workspaceName: string; diff --git a/src/services/analysis/BranchAnalysisService.ts b/src/services/analysis/BranchAnalysisService.ts index e13c86a..afa6da5 100644 --- a/src/services/analysis/BranchAnalysisService.ts +++ b/src/services/analysis/BranchAnalysisService.ts @@ -43,4 +43,82 @@ export class BranchAnalysisService { const refs = changedFiles.map((file) => toFileRef(folder.uri.fsPath, path.join(folder.uri.fsPath, file))); return toBranchSummary(markdown, branchName, baseBranch, refs); } + + public async analyzeFile(filePath: string, baseBranch: string): Promise { + const folder = this.repoScanner.getWorkspaceFolder(); + const git = new GitService(folder.uri.fsPath); + + if (!(await git.isGitRepo())) { + throw new Error("The current workspace is not a git repository."); + } + + const branchName = await git.getCurrentBranch(); + const mergeBase = await git.getMergeBase(baseBranch); + + // Get the diff for just this file (including uncommitted changes) + const comparePoint = mergeBase ?? baseBranch; + let fullDiff = ""; + + try { + // Get committed changes + const committedDiff = await this.runGitCommand(folder.uri.fsPath, [ + "diff", + "--unified=5", + `${comparePoint}..HEAD`, + "--", + filePath, + ]); + fullDiff += committedDiff; + } catch { + // No committed changes + } + + try { + // Get uncommitted changes (staged + working directory) + const uncommittedDiff = await this.runGitCommand(folder.uri.fsPath, [ + "diff", + "--unified=5", + "HEAD", + "--", + filePath, + ]); + if (uncommittedDiff && fullDiff) { + fullDiff += "\n\n" + uncommittedDiff; + } else if (uncommittedDiff) { + fullDiff = uncommittedDiff; + } + } catch { + // No uncommitted changes + } + + if (!fullDiff) { + throw new Error(`No changes found in ${path.basename(filePath)} compared to ${baseBranch}.`); + } + + const provider = createProvider(getProviderConfig()); + const relativePath = path.relative(folder.uri.fsPath, filePath); + const prompt = this.promptBuilder.buildFileComparisonPrompt({ + filePath: relativePath, + branchName, + baseBranch, + diff: fullDiff, + }); + + const markdown = await provider.generate(prompt); + const refs = [toFileRef(folder.uri.fsPath, filePath)]; + return toBranchSummary(markdown, branchName, baseBranch, refs); + } + + private async runGitCommand(cwd: string, args: string[]): Promise { + const { execFile } = await import("child_process"); + const { promisify } = await import("util"); + const execFileAsync = promisify(execFile); + + const { stdout } = await execFileAsync("git", args, { + cwd, + maxBuffer: 1024 * 1024, + }); + + return stdout.trim(); + } } diff --git a/src/services/analysis/PrDescriptionAnalysisService.ts b/src/services/analysis/PrDescriptionAnalysisService.ts new file mode 100644 index 0000000..9b378bf --- /dev/null +++ b/src/services/analysis/PrDescriptionAnalysisService.ts @@ -0,0 +1,395 @@ +import * as path from "path"; +import * as vscode from "vscode"; +import { + ExplanationCard, + FileRef, + PrDescriptionExplanation, + PrDescriptionStyle, + PrState, +} from "../../models/types"; +import { GitHubPullRequest, GitHubRepository, GitHubService } from "../github/GitHubService"; +import { createProvider, getProviderConfig } from "../llm/ProviderFactory"; +import { PromptBuilder } from "../llm/PromptBuilder"; +import { GitService } from "../git/GitService"; +import { RepoScanner } from "../repo/RepoScanner"; +import { toFileRef } from "../../utils/refs"; + +const GENERATED_BLOCK_START = ""; +const GENERATED_BLOCK_END = ""; + +type DraftPayload = { + title?: string; + generatedBody?: string; +}; + +type AnalyzeOptions = { + style?: PrDescriptionStyle; + customInstructions?: string; +}; + +type ApplyDraftOptions = { + draft: PrDescriptionExplanation; + title: string; + body: string; +}; + +type ApplyDraftResult = { + status: "cancelled" | "created" | "updated"; + pullRequest?: GitHubPullRequest; +}; + +export class PrDescriptionAnalysisService { + public constructor( + private readonly repoScanner: RepoScanner, + private readonly promptBuilder: PromptBuilder, + private readonly githubService: GitHubService + ) {} + + public async analyze(options: AnalyzeOptions = {}): Promise { + const folder = this.repoScanner.getWorkspaceFolder(); + const config = vscode.workspace.getConfiguration("codeExplainer"); + const git = new GitService(folder.uri.fsPath); + + if (!(await git.isGitRepo())) { + throw new Error("The current workspace is not a git repository."); + } + + const baseBranch = config.get("baseBranch", "main"); + const style = this.resolveStyle(options.style, config); + const customInstructions = options.customInstructions?.trim() ?? ""; + const defaultGuidelines = config.get("prDescription.defaultGuidelines", "").trim(); + const defaultTemplate = config.get("prDescription.defaultTemplate", "").trim(); + + const [branchName, changedFiles, diff, repository] = await Promise.all([ + git.getCurrentBranch(), + git.getChangedFiles(baseBranch), + git.getDiff(baseBranch), + this.githubService.resolveRepository(git), + ]); + + const hasRemoteBranch = await git.hasRemoteBranch(repository.remoteName, branchName); + const existingPr = await this.githubService.findOpenPullRequest(repository, branchName, true); + const prState = this.getPrState(existingPr); + const existingBody = existingPr?.body ?? ""; + const hasManagedBlock = this.hasManagedBlock(existingBody); + + const provider = createProvider(getProviderConfig()); + const prompt = this.promptBuilder.buildPrDescriptionPrompt({ + branchName, + baseBranch, + changedFiles, + diff, + existingTitle: existingPr?.title, + existingBody, + style, + customInstructions, + teamGuidelines: defaultGuidelines, + template: defaultTemplate, + }); + const payload = this.parseDraftPayload(await provider.generate(prompt)); + + const generatedBody = payload.generatedBody?.trim(); + if (!generatedBody) { + throw new Error("PR description generation did not return any body content."); + } + + const draftTitle = payload.title?.trim() || existingPr?.title || `Update ${branchName}`; + const draftBody = this.composeDraftBody(existingBody, generatedBody); + const cards = this.buildCards({ + rootPath: folder.uri.fsPath, + changedFiles, + prState, + hasManagedBlock, + existingPr, + defaultGuidelines, + defaultTemplate, + }); + + return { + kind: "prDescription", + headline: `Generate PR description for ${branchName}`, + cards, + draftTitle, + draftBody, + style, + customInstructions, + branchName, + baseBranch, + prState, + hasRemoteBranch, + existingPrNumber: existingPr?.number, + existingPrUrl: existingPr?.url, + defaultGuidelines, + defaultTemplate, + }; + } + + public async applyDraft(options: ApplyDraftOptions): Promise { + const folder = this.repoScanner.getWorkspaceFolder(); + const git = new GitService(folder.uri.fsPath); + const repository = await this.githubService.resolveRepository(git); + const branchName = await git.getCurrentBranch(); + + if (branchName !== options.draft.branchName) { + throw new Error("The active branch changed while this PR draft was open. Regenerate the draft and try again."); + } + + const existingPr = await this.githubService.findOpenPullRequest(repository, branchName, true); + const hasRemoteBranch = await git.hasRemoteBranch(repository.remoteName, branchName); + + if (!existingPr) { + if (!hasRemoteBranch) { + const publish = await this.confirmAction( + "This branch is only local. Publish it to GitHub and continue?", + "Publish Branch" + ); + if (!publish) { + return { status: "cancelled" }; + } + + await git.pushCurrentBranch(repository.remoteName); + } + + const create = await this.confirmAction( + "No open PR was found for this branch. Create one with this reviewed title and description?", + "Create PR" + ); + if (!create) { + return { status: "cancelled" }; + } + + const created = await this.githubService.createPullRequest(repository, { + title: options.title, + body: options.body, + headBranch: branchName, + baseBranch: options.draft.baseBranch, + }); + + return { + status: "created", + pullRequest: created, + }; + } + + const update = await this.confirmAction( + "An open PR already exists. Update its title and description with this reviewed draft?", + "Update PR" + ); + if (!update) { + return { status: "cancelled" }; + } + + const updated = await this.githubService.updatePullRequest(repository, existingPr.number, { + title: options.title, + body: options.body, + }); + + return { + status: "updated", + pullRequest: updated, + }; + } + + public withAppliedDraft( + draft: PrDescriptionExplanation, + title: string, + body: string, + pullRequest?: GitHubPullRequest + ): PrDescriptionExplanation { + const cards = [ + { + title: "GitHub Status", + body: pullRequest + ? `Applied to pull request #${pullRequest.number}.` + : "The reviewed draft is ready to apply.", + }, + ...draft.cards.filter((card) => card.title !== "GitHub Status"), + ]; + + return { + ...draft, + cards, + draftTitle: title, + draftBody: body, + prState: "existing-with-description", + hasRemoteBranch: true, + existingPrNumber: pullRequest?.number ?? draft.existingPrNumber, + existingPrUrl: pullRequest?.url ?? draft.existingPrUrl, + }; + } + + private resolveStyle( + overrideStyle: PrDescriptionStyle | undefined, + config: vscode.WorkspaceConfiguration + ): PrDescriptionStyle { + const configStyle = config.get("prDescription.defaultStyle", "manager"); + const allowedStyles: PrDescriptionStyle[] = [ + "business-stakeholder", + "code-collaborator", + "manager", + "other", + ]; + + return allowedStyles.includes(overrideStyle ?? (configStyle as PrDescriptionStyle)) + ? (overrideStyle ?? (configStyle as PrDescriptionStyle)) + : "manager"; + } + + private buildCards(options: { + rootPath: string; + changedFiles: string[]; + prState: PrState; + hasManagedBlock: boolean; + existingPr?: GitHubPullRequest; + defaultGuidelines: string; + defaultTemplate: string; + }): ExplanationCard[] { + const changedFileRefs = options.changedFiles + .slice(0, 12) + .map((file) => toFileRef(options.rootPath, path.join(options.rootPath, file))); + + const cards: ExplanationCard[] = [ + { + title: "GitHub Status", + body: this.describePrState(options.prState, options.hasManagedBlock, options.existingPr), + }, + { + title: "Apply Flow", + body: this.describeApplyFlow(options.prState), + }, + { + title: "Branch Scope", + body: options.changedFiles.length + ? `This draft is based on ${options.changedFiles.length} changed file(s) in the current branch.` + : "No changed files were detected against the base branch.", + refs: changedFileRefs, + }, + ]; + + if (options.defaultGuidelines) { + cards.push({ + title: "Default Guidelines", + body: options.defaultGuidelines, + }); + } + + if (options.defaultTemplate) { + cards.push({ + title: "Default Template", + body: options.defaultTemplate, + }); + } + + return cards; + } + + private describePrState( + prState: PrState, + hasManagedBlock: boolean, + existingPr?: GitHubPullRequest + ): string { + switch (prState) { + case "no-pr": + return "No open pull request was found for the current branch. Applying this draft will create one."; + case "existing-empty": + return existingPr + ? `Found open pull request #${existingPr.number} with an empty description. Applying this draft will fill it in.` + : "Found an open pull request with an empty description."; + case "existing-with-description": + if (hasManagedBlock) { + return existingPr + ? `Found open pull request #${existingPr.number} with an existing generated section. Applying this draft will update the managed content and preserve the rest.` + : "Found an open pull request with an existing generated section."; + } + + return existingPr + ? `Found open pull request #${existingPr.number} with an existing description. Applying this draft will preserve the current content and add or refresh the Code Explainer section.` + : "Found an open pull request with an existing description."; + } + } + + private describeApplyFlow(prState: PrState): string { + switch (prState) { + case "no-pr": + return "Apply to GitHub will confirm branch publishing if needed, then confirm PR creation before any remote changes are made."; + case "existing-empty": + case "existing-with-description": + return "Apply to GitHub will ask for confirmation before updating the pull request title and description."; + } + } + + private getPrState(existingPr?: GitHubPullRequest): PrState { + if (!existingPr) { + return "no-pr"; + } + + return existingPr.body.trim() ? "existing-with-description" : "existing-empty"; + } + + private composeDraftBody(existingBody: string, generatedBody: string): string { + const managedBlock = this.wrapManagedBlock(generatedBody); + if (!existingBody.trim()) { + return managedBlock; + } + + if (this.hasManagedBlock(existingBody)) { + return existingBody.replace( + new RegExp(`${escapeRegExp(GENERATED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(GENERATED_BLOCK_END)}`, "m"), + managedBlock + ).trim(); + } + + return `${existingBody.trim()}\n\n${managedBlock}`.trim(); + } + + private wrapManagedBlock(content: string): string { + return [ + GENERATED_BLOCK_START, + content.trim(), + GENERATED_BLOCK_END, + ].join("\n"); + } + + private hasManagedBlock(body: string): boolean { + return body.includes(GENERATED_BLOCK_START) && body.includes(GENERATED_BLOCK_END); + } + + private parseDraftPayload(raw: string): DraftPayload { + const json = this.extractJson(raw); + return JSON.parse(json) as DraftPayload; + } + + private extractJson(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith("{")) { + return trimmed; + } + + const match = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (match?.[1]) { + return match[1].trim(); + } + + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + return trimmed.slice(start, end + 1); + } + + throw new Error("PR description generation did not return valid JSON."); + } + + private async confirmAction(message: string, actionLabel: string): Promise { + const result = await vscode.window.showInformationMessage( + message, + { modal: true }, + actionLabel + ); + + return result === actionLabel; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/services/analysis/SelectionAnalysisService.ts b/src/services/analysis/SelectionAnalysisService.ts index 9eb7c1a..eb1741c 100644 --- a/src/services/analysis/SelectionAnalysisService.ts +++ b/src/services/analysis/SelectionAnalysisService.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { SelectionExplanation, SelectionTarget, TraceExplanation } from "../../models/types"; +import { FileRef, SelectionExplanation, SelectionTarget, TraceExplanation } from "../../models/types"; import { createProvider, getProviderConfig } from "../llm/ProviderFactory"; import { PromptBuilder } from "../llm/PromptBuilder"; import { RelationshipService } from "../repo/RelationshipService"; @@ -20,25 +20,119 @@ export class SelectionAnalysisService { const folder = this.repoScanner.getWorkspaceFolder(); const context = await this.symbolService.getSelectionContext(editor); const provider = createProvider(getProviderConfig()); + const startLine = editor.selection.start.line + 1; + const endLine = editor.selection.end.line + 1; const prompt = this.promptBuilder.buildSelectionPrompt({ filePath: editor.document.uri.fsPath, selection: context.selectionText, - startLine: editor.selection.start.line + 1, - endLine: editor.selection.end.line + 1, + startLine, + endLine, symbolName: context.symbolName, imports: context.imports, surroundingText: context.surroundingText, }); const markdown = await provider.generate(prompt); - const result = toSelectionExplanation(markdown); + const fileName = editor.document.uri.fsPath.split("/").pop() ?? "file"; + const result = toSelectionExplanation(markdown, fileName, startLine, endLine); + + // Gather related files based on imports, references, and dependencies + const relatedFiles = await this.gatherRelatedFiles(editor, folder.uri.fsPath, context.symbolName); + result.cards.push({ title: "Related Files", - body: "Likely nearby files worth reading next.", - refs: await this.relationshipService.findNearbyFiles(editor.document.uri.fsPath, folder.uri.fsPath), + body: relatedFiles.length > 0 + ? "Files imported by, referencing, or related to the selected code." + : "No directly related files were found for this selection.", + refs: relatedFiles, }); return result; } + private async gatherRelatedFiles( + editor: vscode.TextEditor, + rootPath: string, + symbolName?: string + ): Promise { + const allRefs: FileRef[] = []; + const seen = new Set(); + const currentFilePath = editor.document.uri.fsPath; + + // Helper to check if a file is in the workspace and not a config file + const isWorkspaceFile = (ref: FileRef): boolean => { + if (!ref.path.startsWith(rootPath)) { + return false; + } + + // Exclude common config files + const filename = ref.path.split('/').pop() || ''; + const configPatterns = [ + /^package\.json$/, + /^tsconfig\.json$/, + /\.config\.(js|ts|mjs|cjs)$/, + /^jest\.config/, + /^vitest\.config/, + /^vite\.config/, + /^webpack\.config/, + /^eslint\.config/, + /^prettier\.config/, + ]; + + return !configPatterns.some(pattern => pattern.test(filename)); + }; + + // 1. Get file-level imports/dependencies (most reliable) + const dependsOn = await this.relationshipService["findDependsOn"](editor.document, rootPath); + const workspaceDeps = dependsOn.filter(isWorkspaceFile); + + // 2. Get files that reference this code + const usedBy = await this.relationshipService["findUsedBy"](editor, rootPath); + const workspaceUsedBy = usedBy.filter(isWorkspaceFile); + + // 3. Get function-level relationships (may include workspace files) + const dependsOnFunctions = await this.relationshipService["findDependsOnFunctions"](editor, rootPath, symbolName); + const workspaceDepFunctions = dependsOnFunctions.filter(isWorkspaceFile); + + const usedByFunctions = await this.relationshipService["findUsedByFunctions"](editor, rootPath, symbolName); + const workspaceUsedByFunctions = usedByFunctions.filter(isWorkspaceFile); + + // Prioritize: file imports > usages > function calls + const prioritizedRefs = [ + ...workspaceDeps, + ...workspaceUsedBy, + ...workspaceDepFunctions, + ...workspaceUsedByFunctions, + ]; + + // Add unique refs + for (const ref of prioritizedRefs) { + const key = `${ref.path}:${ref.startLine ?? 0}`; + if (!seen.has(key) && ref.path !== currentFilePath) { + seen.add(key); + allRefs.push(ref); + if (allRefs.length >= 8) { + break; + } + } + } + + // If we found few results, supplement with nearby files (tests, similar names) + if (allRefs.length < 4) { + const nearbyFiles = await this.relationshipService["findNearbyFiles"](currentFilePath, rootPath); + for (const ref of nearbyFiles) { + const key = `${ref.path}:${ref.startLine ?? 0}`; + if (!seen.has(key) && ref.path !== currentFilePath && isWorkspaceFile(ref)) { + seen.add(key); + allRefs.push(ref); + if (allRefs.length >= 8) { + break; + } + } + } + } + + return allRefs.slice(0, 8); + } + public async traceRelationships(target?: SelectionTarget): Promise { const editor = await this.symbolService.resolveEditor(target); const folder = this.repoScanner.getWorkspaceFolder(); diff --git a/src/services/analysis/responseParsing.ts b/src/services/analysis/responseParsing.ts index 8b64c47..34f286f 100644 --- a/src/services/analysis/responseParsing.ts +++ b/src/services/analysis/responseParsing.ts @@ -35,12 +35,29 @@ export function toRepoSummary(markdown: string): RepoSummary { kind: "repo", headline: cards[0]?.body.split("\n")[0] ?? "Repository overview", cards, - nextActions: ["Explain current branch", "Explain selection", "Trace relationships"], + nextActions: ["Explain current branch", "Explain selection", "Trace relationships", "Generate PR Description"], }; } export function toBranchSummary(markdown: string, branchName: string, baseBranch: string, changedFiles: FileRef[]): BranchSummary { - const cards = markdownToCards(markdown); + const cards = markdownToCards(markdown) + .map((card) => { + // Clean up card titles for branch comparisons + let title = card.title; + + // If title contains parenthetical text like "How It Works (Key Changes)", extract just the parenthetical part + const parentheticalMatch = title.match(/^.*\(([^)]+)\)$/); + if (parentheticalMatch) { + title = parentheticalMatch[1]; + } + + return { ...card, title }; + }) + .filter((card) => { + // Remove generic "How It Works" cards since we've extracted specific info + return card.title !== "How It Works"; + }); + return { kind: "branch", headline: cards[0]?.body.split("\n")[0] ?? `Changes in ${branchName}`, @@ -49,16 +66,28 @@ export function toBranchSummary(markdown: string, branchName: string, baseBranch cards, changedFiles, risks: [], + flowChart: undefined, }; } -export function toSelectionExplanation(markdown: string): SelectionExplanation { +export function toSelectionExplanation( + markdown: string, + fileName?: string, + startLine?: number, + endLine?: number +): SelectionExplanation { const cards = markdownToCards(markdown); + let headline = "Selection overview"; + + if (fileName && startLine !== undefined && endLine !== undefined) { + headline = `Explain lines ${startLine}-${endLine} of ${fileName}`; + } + return { kind: "selection", - headline: cards[0]?.body.split("\n")[0] ?? "Selection overview", + headline, cards, - nextActions: ["Draw current branch diagram", "Trace relationships", "Compare branch with main"], + nextActions: ["Draw current branch diagram", "Trace relationships", "Compare branch with main", "Generate PR Description"], }; } @@ -68,7 +97,7 @@ export function toTraceExplanation(markdown: string): TraceExplanation { kind: "trace", headline: cards[0]?.body.split("\n")[0] ?? "Relationship overview", cards, - nextActions: ["Draw current branch diagram", "Explain selection", "Compare branch with main"], + nextActions: ["Draw current branch diagram", "Explain selection", "Compare branch with main", "Generate PR Description"], }; } @@ -87,7 +116,7 @@ export function toFlowExplanation(options: { kind: "flow", headline: options.headline, cards, - nextActions: ["Explain selection", "Trace relationships", "Compare branch with main"], + nextActions: ["Explain selection", "Trace relationships", "Compare branch with main", "Generate PR Description"], flowChart: options.flowChart, }; } diff --git a/src/services/git/GitService.ts b/src/services/git/GitService.ts index 03193cd..0105cdf 100644 --- a/src/services/git/GitService.ts +++ b/src/services/git/GitService.ts @@ -21,6 +21,44 @@ export class GitService { return this.run(["rev-parse", "--abbrev-ref", "HEAD"]); } + public async getPrimaryRemoteName(): Promise { + const remotes = (await this.run(["remote"])) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + if (!remotes.length) { + throw new Error("No git remotes were found for this repository."); + } + + return remotes.includes("origin") ? "origin" : remotes[0]; + } + + public async getRemoteUrl(remoteName: string): Promise { + return this.run(["remote", "get-url", remoteName]); + } + + public async getUpstreamBranch(): Promise { + try { + return await this.run(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); + } catch { + return undefined; + } + } + + public async hasRemoteBranch(remoteName: string, branchName: string): Promise { + try { + const output = await this.run(["ls-remote", "--heads", remoteName, branchName]); + return Boolean(output.trim()); + } catch { + return false; + } + } + + public async pushCurrentBranch(remoteName: string): Promise { + await this.run(["push", "-u", remoteName, "HEAD"]); + } + public async getMergeBase(baseBranch: string): Promise { try { return await this.run(["merge-base", "HEAD", baseBranch]); diff --git a/src/services/github/GitHubService.ts b/src/services/github/GitHubService.ts new file mode 100644 index 0000000..9de06c0 --- /dev/null +++ b/src/services/github/GitHubService.ts @@ -0,0 +1,184 @@ +import * as vscode from "vscode"; +import { GitService } from "../git/GitService"; + +export type GitHubRepository = { + owner: string; + name: string; + remoteName: string; + remoteUrl: string; +}; + +export type GitHubPullRequest = { + number: number; + title: string; + body: string; + url: string; +}; + +type GitHubPullRequestResponse = { + number: number; + title: string; + body: string | null; + html_url: string; +}; + +export class GitHubService { + public async resolveRepository(git: GitService): Promise { + const remoteName = await git.getPrimaryRemoteName(); + const remoteUrl = await git.getRemoteUrl(remoteName); + const parsed = this.parseRemote(remoteUrl); + + if (!parsed) { + throw new Error("Could not resolve a GitHub repository from the current git remote."); + } + + return { + owner: parsed.owner, + name: parsed.name, + remoteName, + remoteUrl, + }; + } + + public async findOpenPullRequest( + repo: GitHubRepository, + branchName: string, + createIfNone = true + ): Promise { + const pulls = await this.request( + `/repos/${repo.owner}/${repo.name}/pulls?state=open&head=${encodeURIComponent(`${repo.owner}:${branchName}`)}&per_page=1`, + { + method: "GET", + }, + createIfNone + ); + + const pull = pulls[0]; + return pull ? this.toPullRequest(pull) : undefined; + } + + public async createPullRequest( + repo: GitHubRepository, + options: { + title: string; + body: string; + headBranch: string; + baseBranch: string; + } + ): Promise { + const response = await this.request( + `/repos/${repo.owner}/${repo.name}/pulls`, + { + method: "POST", + body: JSON.stringify({ + title: options.title, + body: options.body, + head: options.headBranch, + base: options.baseBranch, + }), + }, + true + ); + + return this.toPullRequest(response); + } + + public async updatePullRequest( + repo: GitHubRepository, + pullRequestNumber: number, + options: { + title: string; + body: string; + } + ): Promise { + const response = await this.request( + `/repos/${repo.owner}/${repo.name}/pulls/${pullRequestNumber}`, + { + method: "PATCH", + body: JSON.stringify({ + title: options.title, + body: options.body, + }), + }, + true + ); + + return this.toPullRequest(response); + } + + private async request( + path: string, + init: RequestInit, + createIfNone: boolean + ): Promise { + const session = await vscode.authentication.getSession("github", ["repo"], { createIfNone }); + if (!session) { + throw new Error("GitHub authentication is required to generate PR descriptions."); + } + + const response = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${session.accessToken}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + ...this.normalizeHeaders(init.headers), + }, + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(`GitHub request failed: ${response.status} ${response.statusText}${message ? ` - ${message}` : ""}`); + } + + return response.json() as Promise; + } + + private normalizeHeaders(headers: RequestInit["headers"]): Record { + if (!headers) { + return {}; + } + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers) as Record; + } + + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value]) + ) as Record; + } + + private parseRemote(remoteUrl: string): { owner: string; name: string } | undefined { + const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i); + if (sshMatch?.[1] && sshMatch?.[2]) { + return { + owner: sshMatch[1], + name: sshMatch[2], + }; + } + + const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i); + if (httpsMatch?.[1] && httpsMatch?.[2]) { + return { + owner: httpsMatch[1], + name: httpsMatch[2], + }; + } + + return undefined; + } + + private toPullRequest(pull: GitHubPullRequestResponse): GitHubPullRequest { + return { + number: pull.number, + title: pull.title, + body: pull.body ?? "", + url: pull.html_url, + }; + } +} diff --git a/src/services/llm/PromptBuilder.ts b/src/services/llm/PromptBuilder.ts index bb00d04..5c08915 100644 --- a/src/services/llm/PromptBuilder.ts +++ b/src/services/llm/PromptBuilder.ts @@ -1,4 +1,4 @@ -import { BranchContext, PromptInput, RepoContext, SelectionContext } from "../../models/types"; +import { BranchContext, PrDescriptionStyle, PromptInput, RepoContext, SelectionContext } from "../../models/types"; const sharedSystem = [ "You are an expert software engineer helping a developer understand code.", @@ -36,6 +36,26 @@ export class PromptBuilder { }; } + public buildFileComparisonPrompt(context: { + filePath: string; + branchName: string; + baseBranch: string; + diff: string; + }): PromptInput { + return { + system: sharedSystem, + user: [ + `Analyze the changes made to this file in the current branch compared to ${context.baseBranch}.`, + `Focus on what changed, why it might have changed, and the impact of these changes.`, + `Include both committed changes and any uncommitted local changes.`, + `File: ${context.filePath}`, + `Current branch: ${context.branchName}`, + `Comparing against: ${context.baseBranch}`, + `\nChanges:\n${context.diff}`, + ].join("\n\n"), + }; + } + public buildSelectionPrompt(context: SelectionContext): PromptInput { return { system: sharedSystem, @@ -102,4 +122,55 @@ export class PromptBuilder { ].join("\n\n"), }; } + + public buildPrDescriptionPrompt(context: { + branchName: string; + baseBranch: string; + changedFiles: string[]; + diff: string; + existingTitle?: string; + existingBody?: string; + style: PrDescriptionStyle; + customInstructions?: string; + teamGuidelines?: string; + template?: string; + }): PromptInput { + return { + system: [ + "You write polished GitHub pull request titles and PR summary sections.", + "Return valid JSON only. No markdown fences. No prose outside the JSON object.", + 'Use this schema: {"title": string, "generatedBody": string}.', + "The generatedBody will be inserted into a managed section inside the full PR description, so do not mention markers, automation, or implementation notes about the tool itself.", + "Default shape: a short executive summary followed by clear markdown sections.", + "Be accurate to the supplied diff. If something is inferred, say so carefully.", + "Keep the language readable and specific rather than generic release-note filler.", + ].join(" "), + user: [ + "Generate a PR title and PR description section for the current branch.", + `Audience/style: ${this.getPrStyleGuidance(context.style)}`, + `Current branch: ${context.branchName}`, + `Base branch: ${context.baseBranch}`, + `Changed files:\n${context.changedFiles.join("\n") || "(none)"}`, + `Diff excerpt:\n${context.diff || "(no diff available)"}`, + `Existing PR title:\n${context.existingTitle?.trim() || "(none)"}`, + `Existing PR body:\n${context.existingBody?.trim() || "(none)"}`, + `Team guidelines:\n${context.teamGuidelines?.trim() || "(none)"}`, + `Preferred template:\n${context.template?.trim() || "(none)"}`, + `Custom run instructions:\n${context.customInstructions?.trim() || "(none)"}`, + ].join("\n\n"), + }; + } + + private getPrStyleGuidance(style: PrDescriptionStyle): string { + switch (style) { + case "business-stakeholder": + return "Business stakeholder: non-technical, impact-focused, concise, and outcome-oriented."; + case "code-collaborator": + return "Code collaborator: technical, implementation-aware, explicit about architecture, risks, and testing."; + case "manager": + return "Manager: semi-technical, balancing delivery impact with enough implementation detail to understand scope and risk."; + case "other": + return "Other: use the custom instructions as the primary guide and keep the tone professional."; + } + } } diff --git a/src/ui/sidebar/CodeExplainerProvider.ts b/src/ui/sidebar/CodeExplainerProvider.ts index 5156d2a..7c8ab0e 100644 --- a/src/ui/sidebar/CodeExplainerProvider.ts +++ b/src/ui/sidebar/CodeExplainerProvider.ts @@ -6,6 +6,8 @@ type SidebarNode = { label: string; description?: string; command?: vscode.Command; + collapsibleState?: vscode.TreeItemCollapsibleState; + contextValue?: string; }; export class CodeExplainerProvider implements vscode.TreeDataProvider { @@ -19,53 +21,112 @@ export class CodeExplainerProvider implements vscode.TreeDataProvider ({ - id: `recent:${entry.key}`, - label: entry.label, - description: new Date(entry.updatedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }), - command: { - command: "codeExplainer.openCachedResult", - title: "Open Cached Result", - arguments: [entry.key], - }, - })); + const cachedItems = this.cacheService.list(); + if (cachedItems.length > 0) { + const historyNode: SidebarNode = { + id: "history", + label: "History", + description: `${cachedItems.length} item${cachedItems.length === 1 ? "" : "s"}`, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: "history", + }; + return [...actions, historyNode]; + } - return [...actions, ...recents]; + return actions; + } + + // History node - show category subcategories + if (element.id === "history") { + const cachedItems = this.cacheService.list(); + const categories = [ + { id: "history:repo", label: "Explain Repo", kind: "repo" }, + { id: "history:branch", label: "Compare Branch With Main", kind: "branch" }, + { id: "history:selection", label: "Explain Selection", kind: "selection" }, + { id: "history:trace", label: "Trace Relationships", kind: "trace" }, + { id: "history:flow", label: "Draw Branch Diagram", kind: "flow" }, + ]; + + const categoryNodes: SidebarNode[] = []; + for (const category of categories) { + const itemsInCategory = cachedItems.filter((entry) => entry.source.kind === category.kind); + if (itemsInCategory.length > 0) { + categoryNodes.push({ + id: category.id, + label: category.label, + description: `${itemsInCategory.length}`, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: "historyCategory", + }); + } + } + + return categoryNodes; + } + + // History category nodes - show cached items for that category + if (element.id?.startsWith("history:")) { + const kind = element.id.split(":")[1]; + return this.cacheService + .list() + .filter((entry) => entry.source.kind === kind) + .map((entry) => ({ + id: `recent:${entry.key}`, + label: entry.label, + description: new Date(entry.updatedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }), + command: { + command: "codeExplainer.openCachedResult", + title: "Open Cached Result", + arguments: [entry.key], + }, + })); + } + + return []; } } diff --git a/src/ui/webview/panel.ts b/src/ui/webview/panel.ts index 352eef2..fc7340e 100644 --- a/src/ui/webview/panel.ts +++ b/src/ui/webview/panel.ts @@ -7,6 +7,8 @@ export class ResultsPanel { private onAction: ((action: string) => void) | undefined; private onFileRef: ((fileRef: string) => void) | undefined; private onRefresh: (() => void) | undefined; + private onMessage: ((message: unknown) => void) | undefined; + private currentResult: AnalysisResult | undefined; public constructor(private readonly extensionUri: vscode.Uri) {} @@ -16,18 +18,25 @@ export class ResultsPanel { onAction: (action: string) => void; onFileRef: (fileRef: string) => void; onRefresh: () => void; + onMessage?: (message: unknown) => void; } ): void { const panel = this.ensurePanel(); + this.currentResult = result; this.onAction = handlers.onAction; this.onFileRef = handlers.onFileRef; this.onRefresh = handlers.onRefresh; + this.onMessage = handlers.onMessage; panel.title = `Code Explainer: ${result.kind}`; panel.webview.html = renderHtml("Code Explainer", result); panel.reveal(vscode.ViewColumn.Beside); } + public getCurrentResult(): AnalysisResult | undefined { + return this.currentResult; + } + public showLoading(title: string, message: string): void { const panel = this.ensurePanel(); panel.title = title; @@ -55,6 +64,7 @@ export class ResultsPanel { this.onAction = undefined; this.onFileRef = undefined; this.onRefresh = undefined; + this.onMessage = undefined; }); this.panel.webview.onDidReceiveMessage((message: { type?: string; action?: string; fileRef?: string }) => { @@ -67,7 +77,10 @@ export class ResultsPanel { } if (message.type === "fileRef" && message.fileRef) { this.onFileRef?.(message.fileRef); + return; } + + this.onMessage?.(message); }); return this.panel; diff --git a/src/ui/webview/render.ts b/src/ui/webview/render.ts index f4cc0b2..c68efca 100644 --- a/src/ui/webview/render.ts +++ b/src/ui/webview/render.ts @@ -1,11 +1,20 @@ -import { AnalysisResult, FileRef, FlowChart, FlowLane } from "../../models/types"; +import { + AnalysisResult, + FileRef, + FlowChart, + FlowLane, + PrDescriptionExplanation, + PrDescriptionStyle, +} from "../../models/types"; import { formatFileRef } from "../../utils/refs"; function escapeHtml(input: string): string { return input .replaceAll("&", "&") .replaceAll("<", "<") - .replaceAll(">", ">"); + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } function renderRefs(refs: FileRef[] | undefined): string { @@ -21,7 +30,12 @@ function renderRefs(refs: FileRef[] | undefined): string { } function renderCards(result: AnalysisResult): string { - return result.cards + // For branch and selection, skip the first card since it's already shown in the headline + const cardsToRender = (result.kind === "branch" || result.kind === "selection") + ? result.cards.slice(1) + : result.cards; + + return cardsToRender .map( (card) => `
@@ -90,6 +104,10 @@ function encodeFlowChart(flowChart: FlowChart | undefined): string { } export function renderHtml(title: string, result: AnalysisResult): string { + if (result.kind === "prDescription") { + return renderPrDescriptionHtml(title, result); + } + return ` @@ -347,89 +365,167 @@ export function renderHtml(title: string, result: AnalysisResult): string { return; } + const fontFamily = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; + + // Sort nodes by order const nodes = [...chart.nodes].sort((a, b) => a.order - b.order); - const usedLanes = laneOrder.filter((lane) => nodes.some((node) => node.lane === lane)); - const laneIndexMap = new Map(usedLanes.map((lane, index) => [lane, index])); - const paddingX = 60; - const paddingTop = 86; - const laneGap = 240; - const rowGap = 30; - const laneWidth = 200; - const nodeWidth = 176; - const laneHeights = new Map(); + const usedLanes = laneOrder.filter((lane) => nodes.some((n) => n.lane === lane)); + const laneIndexMap = new Map(usedLanes.map((lane, i) => [lane, i])); + + // Layout constants + const PAD_X = 50; + const PAD_TOP = 80; + const LANE_W = 230; + const LANE_GAP = 40; + const NODE_W = 200; + const NODE_H = 100; + const ROW_GAP = 50; + + // Assign rows using dependency-based greedy packing. + // Each node goes in the earliest row where: + // 1. Its lane slot is not already occupied + // 2. All predecessor nodes (via edges) are in strictly earlier rows + // This compresses the diagram vertically by sharing rows across lanes. + const incomingEdges = new Map(); + for (const edge of chart.edges) { + if (!incomingEdges.has(edge.to)) { + incomingEdges.set(edge.to, new Set()); + } + incomingEdges.get(edge.to).add(edge.from); + } + + const nodeRow = new Map(); + const rowLaneUsed = []; + + for (const node of nodes) { + const preds = incomingEdges.get(node.id) || new Set(); + let minRow = 0; + + for (const predId of preds) { + if (nodeRow.has(predId)) { + minRow = Math.max(minRow, nodeRow.get(predId) + 1); + } + } + + let row = minRow; + while (true) { + if (!rowLaneUsed[row]) { + rowLaneUsed[row] = new Set(); + } + if (!rowLaneUsed[row].has(node.lane)) { + break; + } + row++; + } + + nodeRow.set(node.id, row); + rowLaneUsed[row].add(node.lane); + } + + const totalRows = rowLaneUsed.length; const positioned = nodes.map((node) => { - const index = laneIndexMap.get(node.lane) ?? 0; - const titleLines = wrapText(node.title, 20); - const subtitleLines = wrapText(node.subtitle || "", 28); - const refLine = node.fileRef?.label ? 1 : 0; - const textHeight = titleLines.length * 14 + subtitleLines.length * 12 + refLine * 10; - const nodeHeight = Math.max(92, 28 + textHeight + 16); - const laneOffset = laneHeights.get(node.lane) || 0; - laneHeights.set(node.lane, laneOffset + nodeHeight + rowGap); + const laneIdx = laneIndexMap.get(node.lane) ?? 0; + const row = nodeRow.get(node.id) ?? 0; + + const titleLines = wrapText(node.title, 24); + const subtitleLines = wrapText(node.subtitle || "", 34); + + const laneX = PAD_X + laneIdx * (LANE_W + LANE_GAP); + const nodeX = laneX + (LANE_W - NODE_W) / 2; + const nodeY = PAD_TOP + row * (NODE_H + ROW_GAP); + return { ...node, titleLines, subtitleLines, - x: paddingX + index * laneGap, - y: paddingTop + laneOffset, - width: nodeWidth, - height: nodeHeight, + x: nodeX, + y: nodeY, + width: NODE_W, + height: NODE_H, + row, }; }); - const width = Math.max(860, paddingX * 2 + Math.max(usedLanes.length - 1, 0) * laneGap + laneWidth); - const height = Math.max(680, ...positioned.map((node) => node.y + node.height + 72)); - svg.setAttribute("viewBox", "0 0 " + width + " " + height); - svg.setAttribute("width", String(width)); - svg.setAttribute("height", String(height)); + // SVG dimensions + const svgW = Math.max(860, PAD_X * 2 + usedLanes.length * LANE_W + Math.max(0, usedLanes.length - 1) * LANE_GAP); + const svgH = Math.max(680, PAD_TOP + totalRows * (NODE_H + ROW_GAP) + 50); + + svg.setAttribute("viewBox", "0 0 " + svgW + " " + svgH); + svg.setAttribute("width", String(svgW)); + svg.setAttribute("height", String(svgH)); + svg.style.fontFamily = fontFamily; svg.innerHTML = ""; + // Defs: arrow marker + drop shadow filter const defs = createSvg("defs"); + const marker = createSvg("marker"); marker.setAttribute("id", "arrow"); - marker.setAttribute("markerWidth", "8"); - marker.setAttribute("markerHeight", "6"); - marker.setAttribute("refX", "8"); - marker.setAttribute("refY", "3"); + marker.setAttribute("markerWidth", "10"); + marker.setAttribute("markerHeight", "7"); + marker.setAttribute("refX", "9"); + marker.setAttribute("refY", "3.5"); marker.setAttribute("orient", "auto"); const markerPath = createSvg("path"); - markerPath.setAttribute("d", "M0,0 L8,3 L0,6"); - markerPath.setAttribute("fill", "#9ca3af"); + markerPath.setAttribute("d", "M0,0.5 L8.5,3.5 L0,6.5 L1.5,3.5 Z"); + markerPath.setAttribute("fill", "#94a3b8"); marker.appendChild(markerPath); defs.appendChild(marker); + + const filter = createSvg("filter"); + filter.setAttribute("id", "shadow"); + filter.setAttribute("x", "-4%"); + filter.setAttribute("y", "-4%"); + filter.setAttribute("width", "108%"); + filter.setAttribute("height", "116%"); + const feDropShadow = createSvg("feDropShadow"); + feDropShadow.setAttribute("dx", "0"); + feDropShadow.setAttribute("dy", "2"); + feDropShadow.setAttribute("stdDeviation", "3"); + feDropShadow.setAttribute("flood-color", "rgba(0,0,0,0.08)"); + filter.appendChild(feDropShadow); + defs.appendChild(filter); + svg.appendChild(defs); - usedLanes.forEach((lane) => { - const index = laneIndexMap.get(lane) ?? 0; + // Draw lane backgrounds + usedLanes.forEach((lane, idx) => { const meta = laneMeta[lane]; + const x = PAD_X + idx * (LANE_W + LANE_GAP) - 4; const group = createSvg("g"); - const x = paddingX - 14 + index * laneGap; + const rect = createSvg("rect"); rect.setAttribute("x", String(x)); - rect.setAttribute("y", "28"); - rect.setAttribute("width", String(laneWidth)); - rect.setAttribute("height", String(height - 56)); - rect.setAttribute("rx", "16"); + rect.setAttribute("y", "24"); + rect.setAttribute("width", String(LANE_W + 8)); + rect.setAttribute("height", String(svgH - 48)); + rect.setAttribute("rx", "14"); rect.setAttribute("fill", meta.fill); - rect.setAttribute("fill-opacity", "0.38"); - rect.setAttribute("stroke", "#d1d5db"); - rect.setAttribute("stroke-width", "1.2"); + rect.setAttribute("fill-opacity", "0.28"); + rect.setAttribute("stroke", meta.stroke); + rect.setAttribute("stroke-opacity", "0.15"); + rect.setAttribute("stroke-width", "1"); group.appendChild(rect); const label = createSvg("text"); - label.setAttribute("x", String(x + laneWidth / 2)); - label.setAttribute("y", "52"); + label.setAttribute("x", String(x + (LANE_W + 8) / 2)); + label.setAttribute("y", "50"); label.setAttribute("text-anchor", "middle"); - label.setAttribute("font-size", "13"); + label.setAttribute("font-size", "12"); label.setAttribute("font-weight", "700"); + label.setAttribute("letter-spacing", "0.08em"); label.setAttribute("fill", meta.text); + label.setAttribute("font-family", fontFamily); label.textContent = lane.toUpperCase(); group.appendChild(label); svg.appendChild(group); }); - const byId = new Map(positioned.map((node) => [node.id, node])); + // --- Phase 1: Draw edge paths (collect label data for later) --- + const byId = new Map(positioned.map((n) => [n.id, n])); + const edgeLabels = []; + chart.edges.forEach((edge) => { const from = byId.get(edge.from); const to = byId.get(edge.to); @@ -437,46 +533,61 @@ export function renderHtml(title: string, result: AnalysisResult): string { return; } - const startX = from.x + from.width / 2; - const startY = from.y + from.height; - const endX = to.x + to.width / 2; - const endY = to.y; + const x1 = from.x + from.width / 2; + const y1 = from.y + from.height; + const x2 = to.x + to.width / 2; + const y2 = to.y - 8; + const sameLane = from.lane === to.lane; + let d; + let labelX; + let labelY; + let labelAnchor; + + if (sameLane) { + const rowDiff = to.row - from.row; + if (rowDiff <= 1) { + // Adjacent rows: straight vertical line + d = "M " + x1 + "," + y1 + " L " + x2 + "," + y2; + labelX = x1 + from.width / 2 + 14; + labelY = Math.round((y1 + y2) / 2); + labelAnchor = "start"; + } else { + // Multi-row span: bow right to clear intermediate nodes + const bowX = from.width * 0.7 + 8; + d = "M " + x1 + "," + y1 + + " C " + (x1 + bowX) + "," + (y1 + 30) + + " " + (x2 + bowX) + "," + (y2 - 30) + + " " + x2 + "," + y2; + labelX = Math.round(x1 + bowX * 0.75) + 8; + labelY = Math.round((y1 + y2) / 2); + labelAnchor = "start"; + } + } else { + // Cross-lane: smooth cubic bezier + const dy = Math.abs(y2 - y1); + const cy1 = y1 + dy * 0.3; + const cy2 = y2 - dy * 0.3; + d = "M " + x1 + "," + y1 + " C " + x1 + "," + cy1 + " " + x2 + "," + cy2 + " " + x2 + "," + y2; + labelX = Math.round((x1 + x2) / 2); + labelY = Math.round((y1 + y2) / 2); + labelAnchor = "middle"; + } + const path = createSvg("path"); - const midY = Math.round((startY + endY) / 2); - const d = sameLane - ? "M " + startX + "," + startY + " L " + endX + "," + endY - : "M " + startX + "," + startY + " L " + startX + "," + midY + " L " + endX + "," + midY + " L " + endX + "," + endY; path.setAttribute("d", d); path.setAttribute("fill", "none"); - path.setAttribute("stroke", "#9ca3af"); - path.setAttribute("stroke-width", "2"); + path.setAttribute("stroke", "#94a3b8"); + path.setAttribute("stroke-width", "1.5"); path.setAttribute("marker-end", "url(#arrow)"); svg.appendChild(path); if (edge.label) { - const labelX = sameLane ? startX + 14 : Math.round((startX + endX) / 2); - const labelY = sameLane ? Math.round((startY + endY) / 2) - 8 : midY - 8; - const bg = createSvg("rect"); - bg.setAttribute("x", String(labelX - 48)); - bg.setAttribute("y", String(labelY - 11)); - bg.setAttribute("width", "96"); - bg.setAttribute("height", "18"); - bg.setAttribute("rx", "6"); - bg.setAttribute("fill", "#ffffff"); - svg.appendChild(bg); - - const text = createSvg("text"); - text.setAttribute("x", String(labelX)); - text.setAttribute("y", String(labelY + 2)); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("font-size", "10"); - text.setAttribute("fill", "#6b7280"); - text.textContent = edge.label; - svg.appendChild(text); + edgeLabels.push({ text: edge.label, x: labelX, y: labelY, anchor: labelAnchor }); } }); + // --- Phase 2: Draw nodes on top of edge paths --- positioned.forEach((node) => { const meta = laneMeta[node.lane] || laneMeta.unknown; const group = createSvg("g"); @@ -494,29 +605,33 @@ export function renderHtml(title: string, result: AnalysisResult): string { box.setAttribute("rx", "10"); box.setAttribute("fill", "#ffffff"); box.setAttribute("stroke", meta.stroke); - box.setAttribute("stroke-width", "1.4"); + box.setAttribute("stroke-width", "1.5"); + box.setAttribute("filter", "url(#shadow)"); group.appendChild(box); - node.titleLines.forEach((line, lineIndex) => { + const titleStartY = node.y + 30; + node.titleLines.forEach((line, i) => { const text = createSvg("text"); text.setAttribute("x", String(node.x + node.width / 2)); - text.setAttribute("y", String(node.y + 24 + lineIndex * 14)); + text.setAttribute("y", String(titleStartY + i * 18)); text.setAttribute("text-anchor", "middle"); - text.setAttribute("font-size", "12"); + text.setAttribute("font-size", "13"); text.setAttribute("font-weight", "600"); text.setAttribute("fill", meta.text); + text.setAttribute("font-family", fontFamily); text.textContent = line; group.appendChild(text); }); - const subtitleStartY = node.y + 24 + node.titleLines.length * 14 + 10; - node.subtitleLines.forEach((line, lineIndex) => { + const subtitleStartY = titleStartY + node.titleLines.length * 18 + 8; + node.subtitleLines.forEach((line, i) => { const text = createSvg("text"); text.setAttribute("x", String(node.x + node.width / 2)); - text.setAttribute("y", String(subtitleStartY + lineIndex * 12)); + text.setAttribute("y", String(subtitleStartY + i * 15)); text.setAttribute("text-anchor", "middle"); - text.setAttribute("font-size", "9"); - text.setAttribute("fill", "#6b7280"); + text.setAttribute("font-size", "10"); + text.setAttribute("fill", "#64748b"); + text.setAttribute("font-family", fontFamily); text.textContent = line; group.appendChild(text); }); @@ -524,16 +639,56 @@ export function renderHtml(title: string, result: AnalysisResult): string { if (node.fileRef?.label) { const ref = createSvg("text"); ref.setAttribute("x", String(node.x + node.width / 2)); - ref.setAttribute("y", String(node.y + node.height - 10)); + ref.setAttribute("y", String(node.y + node.height - 14)); ref.setAttribute("text-anchor", "middle"); - ref.setAttribute("font-size", "8"); + ref.setAttribute("font-size", "9"); ref.setAttribute("fill", meta.stroke); - ref.textContent = clampLine(node.fileRef.label, 28); + ref.setAttribute("font-family", fontFamily); + ref.textContent = clampLine(node.fileRef.label, 30); group.appendChild(ref); } svg.appendChild(group); }); + + // --- Phase 3: Draw edge labels on top of everything --- + // Nudge overlapping labels apart + for (let i = 0; i < edgeLabels.length; i++) { + for (let j = 0; j < i; j++) { + if (Math.abs(edgeLabels[i].y - edgeLabels[j].y) < 22 && + Math.abs(edgeLabels[i].x - edgeLabels[j].x) < 140) { + edgeLabels[i].y = edgeLabels[j].y + 26; + } + } + } + + edgeLabels.forEach((lbl) => { + const labelText = clampLine(lbl.text, 36); + const estimatedWidth = labelText.length * 6.5 + 16; + const bgX = lbl.anchor === "start" ? lbl.x - 6 : lbl.x - estimatedWidth / 2; + + const bg = createSvg("rect"); + bg.setAttribute("x", String(bgX)); + bg.setAttribute("y", String(lbl.y - 10)); + bg.setAttribute("width", String(estimatedWidth)); + bg.setAttribute("height", "20"); + bg.setAttribute("rx", "10"); + bg.setAttribute("fill", "#ffffff"); + bg.setAttribute("fill-opacity", "0.95"); + bg.setAttribute("stroke", "#e2e8f0"); + bg.setAttribute("stroke-width", "0.5"); + svg.appendChild(bg); + + const text = createSvg("text"); + text.setAttribute("x", String(lbl.x)); + text.setAttribute("y", String(lbl.y + 4)); + text.setAttribute("text-anchor", lbl.anchor); + text.setAttribute("font-size", "10"); + text.setAttribute("fill", "#64748b"); + text.setAttribute("font-family", fontFamily); + text.textContent = labelText; + svg.appendChild(text); + }); } async function saveFlowAsPng() { @@ -620,6 +775,282 @@ export function renderHtml(title: string, result: AnalysisResult): string { `; } +function renderPrDescriptionHtml(title: string, result: PrDescriptionExplanation): string { + const styleOptions: Array<{ value: PrDescriptionStyle; label: string }> = [ + { value: "business-stakeholder", label: "Business stakeholder" }, + { value: "code-collaborator", label: "Code collaborator" }, + { value: "manager", label: "Manager" }, + { value: "other", label: "Other" }, + ]; + const statusLabel = { + "no-pr": "No PR found", + "existing-empty": "PR found with empty description", + "existing-with-description": "PR found with existing description", + }[result.prState]; + + return ` + + + + + + ${escapeHtml(title)} + + + +

${escapeHtml(title)}

+
+
${escapeHtml(result.headline)}
+
+ ${escapeHtml(statusLabel)} + ${result.hasRemoteBranch ? "Remote branch ready" : "Local branch only"} + ${result.existingPrUrl ? `PR #${escapeHtml(String(result.existingPrNumber ?? ""))}` : ""} +
+
+
+ + + +
+
+
+ + +
+ +
Panel instructions override saved defaults when you regenerate.
+ +
+ ${renderCards(result)} + + + + `; +} + export function renderLoadingHtml(title: string, message: string): string { return `