OXYGENHUB

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 ab81294a9ba9

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 15:53 UTC
Signature
✔ signed by Andy McCall
Hash
ab81294a9ba91d0f0c80c0b919f07fcd92a8d1f7c3fe4dbf79064c925d0521b6
Depends on
35d56b5803d9 bed495a96408 c8f955891224
Depended on by
dac6876e4d08 013fefef11e3 44bfd5adfc9a b4626290e660 f6bda77ecb9f d2c9a5843004 25103dddd993
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
23 atoms, 4 tree operations, 9 files

Browse the tree at this change

added src/blame.ts +140 −0
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 +
}
added src/diffutil.ts +101 −0
1 +
// A line diff (Myers, O(ND)) used to align blame computed on the base
2 +
// version of a file with the lines of the editor's current text.
3 +
4 +
/** For each line of `after`, the index of the matching line in `before`, or -1 when new. */
5 +
export function alignLines(before: string[], after: string[]): number[] {
6 +
  const n = before.length;
7 +
  const m = after.length;
8 +
  const result = new Array<number>(m).fill(-1);
9 +
  if (n === 0 || m === 0) {
10 +
    return result;
11 +
  }
12 +
  // Trim common prefix and suffix first; they dominate real edits.
13 +
  let start = 0;
14 +
  while (start < n && start < m && before[start] === after[start]) {
15 +
    result[start] = start;
16 +
    start++;
17 +
  }
18 +
  let endB = n;
19 +
  let endA = m;
20 +
  while (endB > start && endA > start && before[endB - 1] === after[endA - 1]) {
21 +
    endB--;
22 +
    endA--;
23 +
    result[endA] = endB;
24 +
  }
25 +
  const a = before.slice(start, endB);
26 +
  const b = after.slice(start, endA);
27 +
  if (a.length === 0 || b.length === 0) {
28 +
    return result;
29 +
  }
30 +
  // Bail out to positional matching on huge middles to bound memory.
31 +
  if (a.length * b.length > 25_000_000) {
32 +
    for (let i = 0; i < Math.min(a.length, b.length); i++) {
33 +
      if (a[i] === b[i]) {
34 +
        result[start + i] = start + i;
35 +
      }
36 +
    }
37 +
    return result;
38 +
  }
39 +
  for (const [i, j] of myers(a, b)) {
40 +
    result[start + j] = start + i;
41 +
  }
42 +
  return result;
43 +
}
44 +
45 +
/** Pairs (i, j) of equal lines a[i] === b[j] along a shortest edit script. */
46 +
function myers(a: string[], b: string[]): [number, number][] {
47 +
  const n = a.length;
48 +
  const m = b.length;
49 +
  const max = n + m;
50 +
  const offset = max;
51 +
  const trace: Int32Array[] = [];
52 +
  let v = new Int32Array(2 * max + 2);
53 +
  v[offset + 1] = 0;
54 +
  outer: for (let d = 0; d <= max; d++) {
55 +
    const snapshot = v.slice();
56 +
    trace.push(snapshot);
57 +
    for (let k = -d; k <= d; k += 2) {
58 +
      let x: number;
59 +
      if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) {
60 +
        x = v[offset + k + 1];
61 +
      } else {
62 +
        x = v[offset + k - 1] + 1;
63 +
      }
64 +
      let y = x - k;
65 +
      while (x < n && y < m && a[x] === b[y]) {
66 +
        x++;
67 +
        y++;
68 +
      }
69 +
      v[offset + k] = x;
70 +
      if (x >= n && y >= m) {
71 +
        break outer;
72 +
      }
73 +
    }
74 +
  }
75 +
  // Backtrack.
76 +
  const pairs: [number, number][] = [];
77 +
  let x = n;
78 +
  let y = m;
79 +
  for (let d = trace.length - 1; d >= 0; d--) {
80 +
    const vd = trace[d];
81 +
    const k = x - y;
82 +
    let prevK: number;
83 +
    if (k === -d || (k !== d && vd[offset + k - 1] < vd[offset + k + 1])) {
84 +
      prevK = k + 1;
85 +
    } else {
86 +
      prevK = k - 1;
87 +
    }
88 +
    const prevX = vd[offset + prevK];
89 +
    const prevY = prevX - prevK;
90 +
    while (x > prevX && y > prevY) {
91 +
      x--;
92 +
      y--;
93 +
      pairs.push([x, y]);
94 +
    }
95 +
    if (d > 0) {
96 +
      x = prevX;
97 +
      y = prevY;
98 +
    }
99 +
  }
100 +
  return pairs.reverse();
101 +
}
added src/history.ts +178 −0
1 +
// The history view (spec §5.6): a webview drawing the bookmark's changes
2 +
// as a graph of dependencies, newest first, heads highlighted, with no
3 +
// fake merge nodes. Clicking a change asks the extension to show it.
4 +
5 +
import * as vscode from "vscode";
6 +
import { LogEntry } from "./oxy";
7 +
import { Repository } from "./repository";
8 +
import { timeAgo } from "./operations";
9 +
10 +
export class HistoryPanel {
11 +
  private static panels = new Map<string, HistoryPanel>();
12 +
  private readonly panel: vscode.WebviewPanel;
13 +
  private disposed = false;
14 +
15 +
  static show(repo: Repository, onOpen: (hash: string) => void): void {
16 +
    const existing = HistoryPanel.panels.get(repo.root);
17 +
    if (existing && !existing.disposed) {
18 +
      existing.panel.reveal();
19 +
      void existing.render();
20 +
      return;
21 +
    }
22 +
    HistoryPanel.panels.set(repo.root, new HistoryPanel(repo, onOpen));
23 +
  }
24 +
25 +
  static refresh(root: string): void {
26 +
    const p = HistoryPanel.panels.get(root);
27 +
    if (p && !p.disposed) {
28 +
      void p.render();
29 +
    }
30 +
  }
31 +
32 +
  private constructor(
33 +
    private readonly repo: Repository,
34 +
    onOpen: (hash: string) => void,
35 +
  ) {
36 +
    this.panel = vscode.window.createWebviewPanel("oxygen.history", `History: ${repo.root.split("/").pop()}`, vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true });
37 +
    this.panel.iconPath = new vscode.ThemeIcon("git-commit");
38 +
    this.panel.webview.onDidReceiveMessage((m: { type: string; hash?: string }) => {
39 +
      if (m.type === "open" && m.hash) {
40 +
        onOpen(m.hash);
41 +
      } else if (m.type === "refresh") {
42 +
        void this.render();
43 +
      }
44 +
    });
45 +
    this.panel.onDidDispose(() => {
46 +
      this.disposed = true;
47 +
      HistoryPanel.panels.delete(repo.root);
48 +
    });
49 +
    void this.render();
50 +
  }
51 +
52 +
  private async render(): Promise<void> {
53 +
    let log;
54 +
    try {
55 +
      log = await this.repo.oxy.log(this.repo.root, 300);
56 +
    } catch (e) {
57 +
      this.panel.webview.html = `<p>${escape(String(e))}</p>`;
58 +
      return;
59 +
    }
60 +
    const changes = log.changes;
61 +
    const lanes = layout(changes);
62 +
    this.panel.webview.html = html(this.repo, log.bookmark, changes, lanes);
63 +
  }
64 +
}
65 +
66 +
/** Assign each change a lane so that dependency edges flow downward. */
67 +
function layout(changes: LogEntry[]): Map<string, number> {
68 +
  const lanes = new Map<string, number>();
69 +
  const active: (string | undefined)[] = [];
70 +
  for (const c of changes) {
71 +
    let lane = active.indexOf(c.hash);
72 +
    if (lane < 0) {
73 +
      lane = active.indexOf(undefined);
74 +
      if (lane < 0) {
75 +
        lane = active.length;
76 +
        active.push(undefined);
77 +
      }
78 +
    }
79 +
    lanes.set(c.hash, lane);
80 +
    // The first dependency continues this lane; others take free lanes.
81 +
    const deps = c.deps.filter((d) => changes.some((x) => x.hash === d));
82 +
    active[lane] = deps[0];
83 +
    for (const d of deps.slice(1)) {
84 +
      if (!active.includes(d)) {
85 +
        const free = active.indexOf(undefined, lane + 1);
86 +
        if (free >= 0) {
87 +
          active[free] = d;
88 +
        } else {
89 +
          active.push(d);
90 +
        }
91 +
      }
92 +
    }
93 +
    // Lanes whose expected change has appeared elsewhere are free again.
94 +
    for (let i = 0; i < active.length; i++) {
95 +
      if (active[i] !== undefined && lanes.has(active[i]!)) {
96 +
        active[i] = undefined;
97 +
      }
98 +
    }
99 +
  }
100 +
  return lanes;
101 +
}
102 +
103 +
function html(repo: Repository, bookmark: string, changes: LogEntry[], lanes: Map<string, number>): string {
104 +
  const rowH = 30;
105 +
  const laneW = 18;
106 +
  const maxLane = Math.max(0, ...lanes.values());
107 +
  const graphW = (maxLane + 1) * laneW + 10;
108 +
  const index = new Map(changes.map((c, i) => [c.hash, i]));
109 +
  const edges: string[] = [];
110 +
  const nodes: string[] = [];
111 +
  changes.forEach((c, i) => {
112 +
    const x = lanes.get(c.hash)! * laneW + 10;
113 +
    const y = i * rowH + rowH / 2;
114 +
    for (const d of c.deps) {
115 +
      const j = index.get(d);
116 +
      if (j === undefined) {
117 +
        continue;
118 +
      }
119 +
      const x2 = lanes.get(d)! * laneW + 10;
120 +
      const y2 = j * rowH + rowH / 2;
121 +
      edges.push(`<path d="M${x},${y} C${x},${y + rowH / 2} ${x2},${y2 - rowH / 2} ${x2},${y2}" />`);
122 +
    }
123 +
    const cls = c.working_copy ? "wc" : c.head ? "head" : "node";
124 +
    nodes.push(`<circle class="${cls}" cx="${x}" cy="${y}" r="5" />`);
125 +
  });
126 +
  const rows = changes
127 +
    .map((c, i) => {
128 +
      const subject = c.message.split("\n")[0] || (c.working_copy ? "(working copy)" : "(no description)");
129 +
      const sig = c.working_copy ? "" : c.signature?.status === "signed" || c.signature?.status === "sealed" ? (c.signature.revoked ? "⚠" : "✔") : c.signature?.status === "invalid" ? "✖" : "·";
130 +
      return `<div class="row${c.working_copy ? " wc" : ""}" style="top:${i * rowH}px" data-hash="${c.hash}" title="${escape(c.message)}">
131 +
        <span class="id">${c.change_id.slice(0, 8)}</span>
132 +
        <span class="subject">${escape(subject)}</span>
133 +
        <span class="meta">${escape(c.author)} · ${timeAgo(c.when_ms)} <span class="sig" title="${escape(c.signature?.status ?? "")}">${sig}</span></span>
134 +
      </div>`;
135 +
    })
136 +
    .join("");
137 +
  return `<!DOCTYPE html>
138 +
<html><head><meta charset="utf-8">
139 +
<style>
140 +
  body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); margin: 0; padding: 8px 12px; }
141 +
  h2 { font-size: 13px; font-weight: 600; margin: 0 0 8px; display: flex; gap: 12px; align-items: center; }
142 +
  h2 button { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border: none; padding: 2px 8px; cursor: pointer; }
143 +
  .wrap { position: relative; }
144 +
  svg { position: absolute; left: 0; top: 0; }
145 +
  svg path { fill: none; stroke: var(--vscode-editorCodeLens-foreground); stroke-width: 1.5; }
146 +
  svg circle.node { fill: var(--vscode-editorCodeLens-foreground); }
147 +
  svg circle.head { fill: var(--vscode-charts-green); }
148 +
  svg circle.wc { fill: var(--vscode-charts-blue); stroke: var(--vscode-charts-blue); stroke-width: 2; fill-opacity: .3; }
149 +
  .rows { position: relative; margin-left: ${graphW}px; height: ${changes.length * rowH}px; }
150 +
  .row { position: absolute; left: 0; right: 0; height: ${rowH}px; line-height: ${rowH}px; display: flex; gap: 10px; cursor: pointer; white-space: nowrap; overflow: hidden; }
151 +
  .row:hover { background: var(--vscode-list-hoverBackground); }
152 +
  .row.wc { color: var(--vscode-descriptionForeground); font-style: italic; cursor: default; }
153 +
  .id { color: var(--vscode-charts-blue); font-family: var(--vscode-editor-font-family); }
154 +
  .subject { flex: 1; overflow: hidden; text-overflow: ellipsis; }
155 +
  .meta { color: var(--vscode-descriptionForeground); font-size: 12px; }
156 +
  .sig { margin-left: 4px; }
157 +
</style></head>
158 +
<body>
159 +
<h2>${escape(bookmark)} <span class="meta">${changes.length - 1} changes</span> <button id="refresh">Refresh</button></h2>
160 +
<div class="wrap">
161 +
  <svg width="${graphW}" height="${changes.length * rowH}">${edges.join("")}${nodes.join("")}</svg>
162 +
  <div class="rows">${rows}</div>
163 +
</div>
164 +
<script>
165 +
  const vscode = acquireVsCodeApi();
166 +
  document.querySelectorAll('.row:not(.wc)').forEach(r => r.addEventListener('click', () => vscode.postMessage({ type: 'open', hash: r.dataset.hash })));
167 +
  document.getElementById('refresh').addEventListener('click', () => vscode.postMessage({ type: 'refresh' }));
168 +
</script>
169 +
</body></html>`;
170 +
}
171 +
172 +
function escape(s: string): string {
173 +
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
174 +
}
175 +
176 +
export function repositoryLabel(repo: Repository): string {
177 +
  return repo.root.split("/").pop() ?? repo.root;
178 +
}
added test/diffutil.test.mjs +14 −0
1 +
import { test } from "node:test";
2 +
import assert from "node:assert/strict";
3 +
import { alignLines } from "../out/diffutil.mjs";
4 +
5 +
test("alignLines maps unchanged lines through insertions and deletions", () => {
6 +
  const before = ["a", "b", "c", "d", "e"];
7 +
  const after = ["a", "x", "b", "d", "e", "f"];
8 +
  assert.deepEqual(alignLines(before, after), [0, -1, 1, 3, 4, -1]);
9 +
  assert.deepEqual(alignLines([], ["a"]), [-1]);
10 +
  assert.deepEqual(alignLines(["a"], []), []);
11 +
  assert.deepEqual(alignLines(["same", "same"], ["same", "same"]), [0, 1]);
12 +
  const moved = alignLines(["1", "2", "3", "4"], ["3", "4", "1", "2"]);
13 +
  assert.equal(moved.filter((i) => i >= 0).length, 2, "one block matches, the other reads as moved");
14 +
});
modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## 0.4.0 (2026-09-27)
4 +
5 +
Milestone 0.4: a history graph webview (heads, dependencies, signature marks) with per-change actions (diff, check out, describe, tag, abandon), File History with diffs between versions, and inline blame aligned to unsaved edits.
6 +
3 7
## 0.3.0 (2026-09-27)
4 8
5 9
Milestone 0.3: bookmark picker in the status bar (switch, create, merge, delete), fetch, pull, push and push-bookmark with results, a sync counter and Sync action, confirmation before pushing.
…
20 unchanged lines
modified package.json +27 −6
…
1 unchanged lines
2 2
  "name": "oxygen-vscode",
3 3
  "displayName": "Oxygen",
4 4
  "description": "Oxygen version control: source control view, diffs, decorations, bookmarks and history for oxy repositories",
5 -
  "version": "0.3.0",
5 +
  "version": "0.4.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
40 unchanged lines
49 49
      { "command": "oxygen.pull", "title": "Pull", "category": "Oxygen" },
50 50
      { "command": "oxygen.push", "title": "Push", "category": "Oxygen" },
51 51
      { "command": "oxygen.pushBookmark", "title": "Push Bookmark…", "category": "Oxygen" },
52 -
      { "command": "oxygen.sync", "title": "Sync", "category": "Oxygen", "icon": "$(sync)" }
52 +
      { "command": "oxygen.sync", "title": "Sync", "category": "Oxygen", "icon": "$(sync)" },
53 +
      { "command": "oxygen.showHistory", "title": "Show History", "category": "Oxygen", "icon": "$(git-commit)" },
54 +
      { "command": "oxygen.fileHistory", "title": "File History…", "category": "Oxygen", "icon": "$(history)" },
55 +
      { "command": "oxygen.toggleBlame", "title": "Toggle Inline Blame", "category": "Oxygen" },
56 +
      { "command": "oxygen.showChange", "title": "Show Change", "category": "Oxygen" }
53 57
    ],
54 58
    "views": {
55 59
      "scm": [
…
23 unchanged lines
79 83
        { "command": "oxygen.commitHunks", "group": "2_commit@1", "when": "scmProvider == oxygen" },
80 84
        { "command": "oxygen.amend", "group": "2_commit@2", "when": "scmProvider == oxygen" },
81 85
        { "command": "oxygen.undo", "group": "3_undo", "when": "scmProvider == oxygen" },
82 -
        { "command": "oxygen.showLog", "group": "1_view", "when": "scmProvider == oxygen" },
83 -
        { "command": "oxygen.showOutput", "group": "1_view", "when": "scmProvider == oxygen" }
86 +
        { "command": "oxygen.showHistory", "group": "1_view@1", "when": "scmProvider == oxygen" },
87 +
        { "command": "oxygen.showLog", "group": "1_view@2", "when": "scmProvider == oxygen" },
88 +
        { "command": "oxygen.showOutput", "group": "1_view@3", "when": "scmProvider == oxygen" }
89 +
      ],
90 +
      "editor/title": [
91 +
        { "command": "oxygen.fileHistory", "group": "navigation@9", "when": "oxygen.hasRepository && resourceScheme == file" }
84 92
      ],
93 +
      "editor/context": [
94 +
        { "command": "oxygen.toggleBlame", "group": "oxygen@1", "when": "oxygen.hasRepository && resourceScheme == file" },
95 +
        { "command": "oxygen.fileHistory", "group": "oxygen@2", "when": "oxygen.hasRepository && resourceScheme == file" }
96 +
      ],
97 +
      "explorer/context": [
98 +
        { "command": "oxygen.fileHistory", "group": "7_modification@9", "when": "oxygen.hasRepository && !explorerResourceIsFolder" }
99 +
      ],
85 100
      "scm/resourceState/context": [
86 101
        { "command": "oxygen.openFile", "group": "inline@1", "when": "scmProvider == oxygen" },
87 102
        { "command": "oxygen.discard", "group": "inline@2", "when": "scmProvider == oxygen" },
…
15 unchanged lines
103 118
        { "command": "oxygen.discard", "when": "false" },
104 119
        { "command": "oxygen.commitSelected", "when": "false" },
105 120
        { "command": "oxygen.undoTo", "when": "false" },
106 -
        { "command": "oxygen.refreshOperations", "when": "false" }
121 +
        { "command": "oxygen.refreshOperations", "when": "false" },
122 +
        { "command": "oxygen.showChange", "when": "false" }
107 123
      ]
108 124
    },
109 125
    "configuration": {
…
14 unchanged lines
124 140
          "default": true,
125 141
          "description": "Show status decorations in the explorer and on editor tabs."
126 142
        },
143 +
        "oxygen.inlineBlame": {
144 +
          "type": "boolean",
145 +
          "default": false,
146 +
          "description": "Show each line's introducing change, author and age at the end of the line."
147 +
        },
127 148
        "oxygen.confirmSync": {
128 149
          "type": "boolean",
129 150
          "default": true,
…
16 unchanged lines
146 167
    "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap",
147 168
    "watch": "npm run build -- --watch",
148 169
    "typecheck": "tsc --noEmit -p .",
149 -
    "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --outfile=out/oxy.mjs",
170 +
    "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --outfile=out/oxy.mjs && esbuild src/diffutil.ts --bundle --platform=node --format=esm --outfile=out/diffutil.mjs",
150 171
    "test": "node --test \"test/**/*.test.mjs\"",
151 172
    "check": "npm run typecheck && npm run build && npm test",
152 173
    "package": "vsce package --no-dependencies"
…
9 unchanged lines
modified src/extension.ts +156 −2
…
4 unchanged lines
5 5
import { basename } from "node:path";
6 6
import { Oxy, OxyError, findBinary } from "./oxy";
7 7
import { Repository, OxyResourceState } from "./repository";
8 -
import { OxyContentProvider, SCHEME, baseUri } from "./contentProvider";
8 +
import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider";
9 9
import { OxyDecorationProvider } from "./decorations";
10 -
import { OperationItem, OperationsProvider } from "./operations";
10 +
import { OperationItem, OperationsProvider, timeAgo } from "./operations";
11 +
import { BlameController } from "./blame";
12 +
import { HistoryPanel } from "./history";
11 13
12 14
const MIN_VERSION = { format: 2, protocol: 1 };
13 15
…
2 unchanged lines
16 18
let contentProvider: OxyContentProvider;
17 19
let decorations: OxyDecorationProvider;
18 20
let operations: OperationsProvider;
21 +
let blame: BlameController;
19 22
const repositories = new Map<string, Repository>();
20 23
21 24
export async function activate(context: vscode.ExtensionContext): Promise<void> {
…
22 unchanged lines
44 47
  context.subscriptions.push(decorations);
45 48
  operations = new OperationsProvider(() => [...repositories.values()]);
46 49
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
50 +
  blame = new BlameController(() => [...repositories.values()]);
51 +
  context.subscriptions.push(blame);
47 52
48 53
  context.subscriptions.push(
49 54
    vscode.commands.registerCommand("oxygen.refresh", async () => {
…
22 unchanged lines
72 77
    vscode.commands.registerCommand("oxygen.push", (arg?: Repository | vscode.SourceControl) => push(arg, false)),
73 78
    vscode.commands.registerCommand("oxygen.pushBookmark", (arg?: Repository | vscode.SourceControl) => push(arg, true)),
74 79
    vscode.commands.registerCommand("oxygen.sync", (arg?: Repository | vscode.SourceControl) => sync(arg)),
80 +
    vscode.commands.registerCommand("oxygen.showHistory", (arg?: Repository | vscode.SourceControl) => {
81 +
      const repo = repositoryFor(arg);
82 +
      if (repo) {
83 +
        HistoryPanel.show(repo, (hash) => void showChange(repo.root, hash));
84 +
      }
85 +
    }),
86 +
    vscode.commands.registerCommand("oxygen.fileHistory", (uri?: vscode.Uri) => fileHistory(uri ?? vscode.window.activeTextEditor?.document.uri)),
87 +
    vscode.commands.registerCommand("oxygen.toggleBlame", () => blame.toggle()),
88 +
    vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)),
75 89
    vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
76 90
    vscode.workspace.onDidChangeConfiguration((e) => {
77 91
      if (e.affectsConfiguration("oxygen.path")) {
…
44 unchanged lines
122 136
        decorations.refresh();
123 137
        contentProvider.invalidate(root);
124 138
        operations.refresh();
139 +
        blame.invalidate(root);
140 +
        HistoryPanel.refresh(root);
125 141
      });
126 142
      repositories.set(root, repo);
127 143
      output.appendLine(`repository ${root}`);
…
190 unchanged lines
318 334
  }
319 335
}
320 336
337 +
// ---------------------------------------------------------------- history
338 +
339 +
async function showChange(root: string, hash: string): Promise<void> {
340 +
  const repo = repositories.get(root);
341 +
  if (!repo) {
342 +
    return;
343 +
  }
344 +
  let info;
345 +
  try {
346 +
    info = await repo.oxy.show(repo.root, `#${hash.slice(0, 12)}`);
347 +
  } catch (e) {
348 +
    showError(e);
349 +
    return;
350 +
  }
351 +
  const subject = info.message.split("\n")[0];
352 +
  type Item = vscode.QuickPickItem & { action: string; path?: string };
353 +
  const items: Item[] = [
354 +
    { label: "$(diff) Show diff", description: `${info.files.length} file${info.files.length === 1 ? "" : "s"}`, action: "diff" },
355 +
    { label: "$(arrow-right) Check out this state", description: "the change and its dependencies", action: "checkout" },
356 +
    { label: "$(edit) Describe…", description: "change the message", action: "describe" },
357 +
    { label: "$(tag) Tag…", action: "tag" },
358 +
    { label: "$(trash) Abandon", description: "drop the change; its content stays on disk if the working copy sat on it", action: "abandon" },
359 +
    { label: "$(copy) Copy ChangeId", action: "copy" },
360 +
    ...info.files.map((f) => ({ label: `$(file) ${f.path}`, description: `${f.kind}${f.from ? ` from ${f.from}` : ""}`, action: "file", path: f.path })),
361 +
  ];
362 +
  const pick = await vscode.window.showQuickPick(items, {
363 +
    title: `${info.change_id.slice(0, 8)}  ${subject}`,
364 +
    placeHolder: `${info.author} · ${timeAgo(info.when_ms)} · ${info.signature?.status ?? "unsigned"}${info.signature?.revoked ? " (key revoked)" : ""} · ${info.deps.length} dependenc${info.deps.length === 1 ? "y" : "ies"}`,
365 +
    matchOnDescription: true,
366 +
  });
367 +
  if (!pick) {
368 +
    return;
369 +
  }
370 +
  try {
371 +
    switch (pick.action) {
372 +
      case "diff":
373 +
      case "file": {
374 +
        const text = await repo.oxy.diffText(repo.root, `#${hash.slice(0, 12)}`);
375 +
        const body = `# ${info.change_id.slice(0, 8)} ${subject}\n# ${info.author} · ${new Date(info.when_ms).toLocaleString()}\n\n${text}`;
376 +
        const doc = await vscode.workspace.openTextDocument({ content: body, language: "diff" });
377 +
        await vscode.window.showTextDocument(doc, { preview: true });
378 +
        return;
379 +
      }
380 +
      case "checkout":
381 +
        await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`]);
382 +
        break;
383 +
      case "describe": {
384 +
        const message = await vscode.window.showInputBox({ prompt: "New message", value: info.message });
385 +
        if (!message?.trim()) {
386 +
          return;
387 +
        }
388 +
        await repo.oxy.describe(repo.root, `#${hash.slice(0, 12)}`, message.trim());
389 +
        break;
390 +
      }
391 +
      case "tag": {
392 +
        const name = await vscode.window.showInputBox({ prompt: "Tag name" });
393 +
        if (!name?.trim()) {
394 +
          return;
395 +
        }
396 +
        await repo.oxy.tagCreate(repo.root, name.trim(), [`#${hash.slice(0, 12)}`]);
397 +
        break;
398 +
      }
399 +
      case "abandon": {
400 +
        const confirm = await vscode.window.showWarningMessage(`Abandon "${subject}"?`, { modal: true }, "Abandon");
401 +
        if (confirm !== "Abandon") {
402 +
          return;
403 +
        }
404 +
        const out = await repo.oxy.abandon(repo.root, `#${hash.slice(0, 12)}`);
405 +
        vscode.window.setStatusBarMessage(`$(trash) ${out.split("\n")[0]}`, 5000);
406 +
        break;
407 +
      }
408 +
      case "copy":
409 +
        await vscode.env.clipboard.writeText(info.change_id);
410 +
        return;
411 +
    }
412 +
    await repo.refresh();
413 +
  } catch (e) {
414 +
    if (e instanceof OxyError && e.message.includes("has changes")) {
415 +
      const confirm = await vscode.window.showWarningMessage("The working copy has uncommitted changes. Discard them and check out?", { modal: true }, "Discard and check out");
416 +
      if (confirm) {
417 +
        try {
418 +
          await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`], true);
419 +
          await repo.refresh();
420 +
        } catch (e2) {
421 +
          showError(e2);
422 +
        }
423 +
      }
424 +
      return;
425 +
    }
426 +
    showError(e);
427 +
  }
428 +
}
429 +
430 +
async function fileHistory(uri: vscode.Uri | undefined): Promise<void> {
431 +
  if (!uri) {
432 +
    return;
433 +
  }
434 +
  const repo = [...repositories.values()].find((r) => r.relativePath(uri) !== undefined);
435 +
  const rel = repo?.relativePath(uri);
436 +
  if (!repo || !rel) {
437 +
    void vscode.window.showInformationMessage("Oxygen: this file is not in an Oxygen repository.");
438 +
    return;
439 +
  }
440 +
  try {
441 +
    const log = await repo.oxy.log(repo.root, 200, rel);
442 +
    const changes = log.changes.filter((c) => !c.working_copy);
443 +
    if (changes.length === 0) {
444 +
      void vscode.window.showInformationMessage(`Oxygen: no committed change touches ${rel}.`);
445 +
      return;
446 +
    }
447 +
    const pick = await vscode.window.showQuickPick(
448 +
      changes.map((c, i) => ({
449 +
        label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
450 +
        description: `${c.change_id.slice(0, 8)} · ${c.author} · ${timeAgo(c.when_ms)}`,
451 +
        change: c,
452 +
        previous: changes[i + 1],
453 +
      })),
454 +
      { placeHolder: `${changes.length} change${changes.length === 1 ? "" : "s"} touched ${rel}; pick one to compare with the version before it`, matchOnDescription: true },
455 +
    );
456 +
    if (!pick) {
457 +
      return;
458 +
    }
459 +
    const after = oxyUriFor(repo.root, rel, pick.change.hash);
460 +
    if (pick.previous) {
461 +
      const before = oxyUriFor(repo.root, rel, pick.previous.hash);
462 +
      await vscode.commands.executeCommand("vscode.diff", before, after, `${basename(rel)} (${pick.previous.change_id.slice(0, 8)} ↔ ${pick.change.change_id.slice(0, 8)})`);
463 +
    } else {
464 +
      await vscode.commands.executeCommand("vscode.open", after);
465 +
    }
466 +
  } catch (e) {
467 +
    showError(e);
468 +
  }
469 +
}
470 +
471 +
function oxyUriFor(root: string, rel: string, hash: string): vscode.Uri {
472 +
  return oxyUri(root, rel, `#${hash.slice(0, 12)}`);
473 +
}
474 +
321 475
// ---------------------------------------------------------------- bookmarks
322 476
323 477
async function switchBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
…
306 unchanged lines
modified src/oxy.ts +44 −0
…
222 unchanged lines
223 223
  clone(cwd: string, url: string, dest: string): Promise<void> {
224 224
    return this.check(cwd, ["clone", url, dest]).then(() => undefined);
225 225
  }
226 +
227 +
  // ---------------------------------------------------------------- history and blame
228 +
229 +
  blame(root: string, path: string): Promise<BlameLine[]> {
230 +
    return this.json<BlameLine[]>(root, ["blame", path]);
231 +
  }
232 +
233 +
  show(root: string, change: string): Promise<ShowInfo> {
234 +
    return this.json<ShowInfo>(root, ["show", change]);
235 +
  }
236 +
237 +
  /** The rendered diff of a change (or the working copy), as `oxy diff` prints it. */
238 +
  diffText(root: string, change?: string): Promise<string> {
239 +
    return this.check(root, ["diff", ...(change ? [change] : [])]).then((r) => r.stdout.toString("utf8"));
240 +
  }
241 +
242 +
  describe(root: string, change: string, message: string): Promise<void> {
243 +
    return this.check(root, ["describe", change, "-m", message]).then(() => undefined);
244 +
  }
245 +
246 +
  abandon(root: string, change: string): Promise<string> {
247 +
    return this.check(root, ["abandon", change]).then((r) => r.stdout.toString("utf8").trim());
248 +
  }
249 +
250 +
  checkout(root: string, changes: string[], discard = false): Promise<void> {
251 +
    return this.check(root, ["checkout", ...changes, ...(discard ? ["--discard"] : [])]).then(() => undefined);
252 +
  }
253 +
254 +
  tagCreate(root: string, name: string, changes: string[] = [], message = ""): Promise<void> {
255 +
    return this.check(root, ["tag", "create", name, ...changes, "-m", message]).then(() => undefined);
256 +
  }
257 +
}
258 +
259 +
export interface BlameLine {
260 +
  line: number;
261 +
  change_id: string;
262 +
  hash: string;
263 +
  index: number;
264 +
  author: string;
265 +
  when_ms: number;
266 +
}
267 +
268 +
export interface ShowInfo extends LogEntry {
269 +
  files: { path: string; kind: string; from: string | null }[];
226 270
}
227 271
228 272
export interface RemoteInfo {
…
102 unchanged lines
modified test/oxy.test.mjs +11 −0
…
111 unchanged lines
112 112
  await oxy.bookmarkDelete(root, "feature");
113 113
  bookmarks = await oxy.bookmarks(root);
114 114
  assert.deepEqual(bookmarks.map((b) => b.name), ["main"]);
115 +
116 +
  // Blame, show, diff text, and file history.
117 +
  const blame = await oxy.blame(root, "a.txt");
118 +
  assert.deepEqual(blame.map((l) => l.line), [1, 2]);
119 +
  assert.notEqual(blame[0].hash, blame[1].hash, "two changes introduced the two lines");
120 +
  const show = await oxy.show(root, `#${blame[1].hash.slice(0, 12)}`);
121 +
  assert.equal(show.message, "feature work");
122 +
  assert.deepEqual(show.files.map((f) => f.path), ["a.txt"]);
123 +
  assert.match(await oxy.diffText(root, `#${blame[1].hash.slice(0, 12)}`), /\+feature/);
124 +
  const history = await oxy.log(root, 50, "a.txt");
125 +
  assert.equal(history.changes.filter((c) => !c.working_copy).length, 2);
115 126
});
116 127