OXYGENHUB
# dac6876e4d08 oxygen-vscode / src / blame.ts
5.1 KiB · last changed Milestone 0.4: history graph webview with per-change actions, file history diffs between versions, inline blame aligned to unsaved edits with a Myers line diff 1 month ago source blame history raw
ab81294a Andy McCall ↶ 1
// Inline blame (spec §5.8): each line's introducing change, author and age
2
// at the end of the line, computed on the base version and aligned to the
3
// editor's current text so unsaved edits show as unattributed.
4
5
import * as vscode from "vscode";
6
import { basename } from "node:path";
7
import { alignLines } from "./diffutil";
8
import { BlameLine } from "./oxy";
9
import { Repository } from "./repository";
10
import { timeAgo } from "./operations";
11
12
export class BlameController implements vscode.Disposable {
13
  private enabled: boolean;
14
  private readonly decoration: vscode.TextEditorDecorationType;
15
  private readonly disposables: vscode.Disposable[] = [];
16
  private cache = new Map<string, { base: string; lines: BlameLine[] }>();
17
  private timer: NodeJS.Timeout | undefined;
18
19
  constructor(private readonly repositories: () => Repository[]) {
20
    this.enabled = vscode.workspace.getConfiguration("oxygen").get<boolean>("inlineBlame", false);
21
    this.decoration = vscode.window.createTextEditorDecorationType({
22
      after: { margin: "0 0 0 3em", color: new vscode.ThemeColor("editorCodeLens.foreground"), fontStyle: "italic" },
23
      rangeBehavior: vscode.DecorationRangeBehavior.ClosedOpen,
24
    });
25
    this.disposables.push(
26
      this.decoration,
27
      vscode.window.onDidChangeActiveTextEditor(() => this.schedule()),
28
      vscode.workspace.onDidChangeTextDocument((e) => e.document === vscode.window.activeTextEditor?.document && this.schedule()),
29
      vscode.workspace.onDidSaveTextDocument(() => this.schedule()),
30
      vscode.workspace.onDidChangeConfiguration((e) => {
31
        if (e.affectsConfiguration("oxygen.inlineBlame")) {
32
          this.enabled = vscode.workspace.getConfiguration("oxygen").get<boolean>("inlineBlame", false);
33
          this.schedule();
34
        }
35
      }),
36
    );
37
    this.schedule();
38
  }
39
40
  toggle(): void {
41
    this.enabled = !this.enabled;
42
    void vscode.workspace.getConfiguration("oxygen").update("inlineBlame", this.enabled, vscode.ConfigurationTarget.Global);
43
    this.schedule();
44
  }
45
46
  /** Forget cached blame for a repository (after any operation). */
47
  invalidate(root: string): void {
48
    for (const key of [...this.cache.keys()]) {
49
      if (key.startsWith(root + "\0")) {
50
        this.cache.delete(key);
51
      }
52
    }
53
    this.schedule();
54
  }
55
56
  private schedule(): void {
57
    if (this.timer) {
58
      clearTimeout(this.timer);
59
    }
60
    this.timer = setTimeout(() => void this.apply(), 200);
61
  }
62
63
  private async apply(): Promise<void> {
64
    const editor = vscode.window.activeTextEditor;
65
    if (!editor) {
66
      return;
67
    }
68
    if (!this.enabled) {
69
      editor.setDecorations(this.decoration, []);
70
      return;
71
    }
72
    const repo = this.repositories().find((r) => r.relativePath(editor.document.uri) !== undefined);
73
    const rel = repo?.relativePath(editor.document.uri);
74
    if (!repo || !rel) {
75
      editor.setDecorations(this.decoration, []);
76
      return;
77
    }
78
    const key = repo.root + "\0" + rel;
79
    let entry = this.cache.get(key);
80
    if (!entry) {
81
      try {
82
        const [base, lines] = await Promise.all([repo.oxy.cat(repo.root, rel), repo.oxy.blame(repo.root, rel)]);
83
        entry = { base: base.toString("utf8"), lines };
84
        this.cache.set(key, entry);
85
      } catch {
86
        editor.setDecorations(this.decoration, []);
87
        return;
88
      }
89
    }
90
    if (vscode.window.activeTextEditor !== editor) {
91
      return;
92
    }
93
    const baseLines = splitLines(entry.base);
94
    const nowLines = splitLines(editor.document.getText());
95
    const map = alignLines(baseLines, nowLines);
96
    const byLine = new Map(entry.lines.map((l) => [l.line - 1, l]));
97
    const decorations: vscode.DecorationOptions[] = [];
98
    for (let i = 0; i < editor.document.lineCount; i++) {
99
      const baseIndex = map[i];
100
      const blame = baseIndex >= 0 ? byLine.get(baseIndex) : undefined;
101
      const text = blame ? `${blame.change_id.slice(0, 8)} · ${blame.author} · ${timeAgo(blame.when_ms)}` : i < nowLines.length && nowLines[i].length > 0 ? "uncommitted" : "";
102
      if (!text) {
103
        continue;
104
      }
105
      const line = editor.document.lineAt(i);
106
      decorations.push({
107
        range: new vscode.Range(line.range.end, line.range.end),
108
        renderOptions: { after: { contentText: text } },
109
        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,
110
      });
111
    }
112
    for (const d of decorations) {
113
      if (d.hoverMessage instanceof vscode.MarkdownString) {
114
        d.hoverMessage.isTrusted = true;
115
      }
116
    }
117
    editor.setDecorations(this.decoration, decorations);
118
  }
119
120
  dispose(): void {
121
    if (this.timer) {
122
      clearTimeout(this.timer);
123
    }
124
    for (const d of this.disposables) {
125
      d.dispose();
126
    }
127
  }
128
}
129
130
function splitLines(text: string): string[] {
131
  const lines = text.split(/\r?\n/);
132
  if (lines.length > 0 && lines[lines.length - 1] === "") {
133
    lines.pop();
134
  }
135
  return lines;
136
}
137
138
export function blameTitle(path: string): string {
139
  return `Blame: ${basename(path)}`;
140
}