// Inline blame (spec §5.8): each line's introducing change, author and age // at the end of the line, computed on the base version and aligned to the // editor's current text so unsaved edits show as unattributed. import * as vscode from "vscode"; import { basename } from "node:path"; import { alignLines } from "./diffutil"; import { BlameLine } from "./oxy"; import { Repository } from "./repository"; import { timeAgo } from "./operations"; export class BlameController implements vscode.Disposable { private enabled: boolean; private readonly decoration: vscode.TextEditorDecorationType; private readonly disposables: vscode.Disposable[] = []; private cache = new Map(); private timer: NodeJS.Timeout | undefined; constructor(private readonly repositories: () => Repository[]) { this.enabled = vscode.workspace.getConfiguration("oxygen").get("inlineBlame", false); this.decoration = vscode.window.createTextEditorDecorationType({ after: { margin: "0 0 0 3em", color: new vscode.ThemeColor("editorCodeLens.foreground"), fontStyle: "italic" }, rangeBehavior: vscode.DecorationRangeBehavior.ClosedOpen, }); this.disposables.push( this.decoration, vscode.window.onDidChangeActiveTextEditor(() => this.schedule()), vscode.workspace.onDidChangeTextDocument((e) => e.document === vscode.window.activeTextEditor?.document && this.schedule()), vscode.workspace.onDidSaveTextDocument(() => this.schedule()), vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("oxygen.inlineBlame")) { this.enabled = vscode.workspace.getConfiguration("oxygen").get("inlineBlame", false); this.schedule(); } }), ); this.schedule(); } toggle(): void { this.enabled = !this.enabled; void vscode.workspace.getConfiguration("oxygen").update("inlineBlame", this.enabled, vscode.ConfigurationTarget.Global); this.schedule(); } /** Forget cached blame for a repository (after any operation). */ invalidate(root: string): void { for (const key of [...this.cache.keys()]) { if (key.startsWith(root + "\0")) { this.cache.delete(key); } } this.schedule(); } private schedule(): void { if (this.timer) { clearTimeout(this.timer); } this.timer = setTimeout(() => void this.apply(), 200); } private async apply(): Promise { const editor = vscode.window.activeTextEditor; if (!editor) { return; } if (!this.enabled) { editor.setDecorations(this.decoration, []); return; } const repo = this.repositories().find((r) => r.relativePath(editor.document.uri) !== undefined); const rel = repo?.relativePath(editor.document.uri); if (!repo || !rel) { editor.setDecorations(this.decoration, []); return; } const key = repo.root + "\0" + rel; let entry = this.cache.get(key); if (!entry) { try { const [base, lines] = await Promise.all([repo.oxy.cat(repo.root, rel), repo.oxy.blame(repo.root, rel)]); entry = { base: base.toString("utf8"), lines }; this.cache.set(key, entry); } catch { editor.setDecorations(this.decoration, []); return; } } if (vscode.window.activeTextEditor !== editor) { return; } const baseLines = splitLines(entry.base); const nowLines = splitLines(editor.document.getText()); const map = alignLines(baseLines, nowLines); const byLine = new Map(entry.lines.map((l) => [l.line - 1, l])); const decorations: vscode.DecorationOptions[] = []; for (let i = 0; i < editor.document.lineCount; i++) { const baseIndex = map[i]; const blame = baseIndex >= 0 ? byLine.get(baseIndex) : undefined; const text = blame ? `${blame.change_id.slice(0, 8)} · ${blame.author} · ${timeAgo(blame.when_ms)}` : i < nowLines.length && nowLines[i].length > 0 ? "uncommitted" : ""; if (!text) { continue; } const line = editor.document.lineAt(i); decorations.push({ range: new vscode.Range(line.range.end, line.range.end), renderOptions: { after: { contentText: text } }, hoverMessage: blame ? new vscode.MarkdownString(`**${blame.author}** · ${new Date(blame.when_ms).toLocaleString()}\n\nChange \`${blame.change_id}\`\n\n[Show change](command:oxygen.showChange?${encodeURIComponent(JSON.stringify([repo.root, blame.hash]))})`, true) : undefined, }); } for (const d of decorations) { if (d.hoverMessage instanceof vscode.MarkdownString) { d.hoverMessage.isTrusted = true; } } editor.setDecorations(this.decoration, decorations); } dispose(): void { if (this.timer) { clearTimeout(this.timer); } for (const d of this.disposables) { d.dispose(); } } } function splitLines(text: string): string[] { const lines = text.split(/\r?\n/); if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } return lines; } export function blameTitle(path: string): string { return `Blame: ${basename(path)}`; }