OXYGENHUB
# 44bfd5adfc9a oxygen-vscode / src / history.ts
7.6 KiB · last changed History graph: split, squash and rebase from the change picker or a right-click menu, confirmed first and undoable; 0.8.0 1 month ago source blame history raw
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
      // Right-click: split, squash and rebase (webview/context menus in package.json).
131
      const ctx = c.working_copy ? "" : ` data-vscode-context='${escape(JSON.stringify({ webviewSection: "change", root: repo.root, hash: c.hash, oxygenHead: c.head, oxygenHasDeps: c.deps.length > 0, preventDefaultContextMenuItems: true }))}'`;
132
      return `<div class="row${c.working_copy ? " wc" : ""}" style="top:${i * rowH}px" data-hash="${c.hash}" title="${escape(c.message)}"${ctx}>
133
        <span class="id">${c.change_id.slice(0, 8)}</span>
134
        <span class="subject">${escape(subject)}</span>
135
        <span class="meta">${escape(c.author)} · ${timeAgo(c.when_ms)} <span class="sig" title="${escape(c.signature?.status ?? "")}">${sig}</span></span>
136
      </div>`;
137
    })
138
    .join("");
139
  return `<!DOCTYPE html>
140
<html><head><meta charset="utf-8">
141
<style>
142
  body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); margin: 0; padding: 8px 12px; }
143
  h2 { font-size: 13px; font-weight: 600; margin: 0 0 8px; display: flex; gap: 12px; align-items: center; }
144
  h2 button { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border: none; padding: 2px 8px; cursor: pointer; }
145
  .wrap { position: relative; }
146
  svg { position: absolute; left: 0; top: 0; }
147
  svg path { fill: none; stroke: var(--vscode-editorCodeLens-foreground); stroke-width: 1.5; }
148
  svg circle.node { fill: var(--vscode-editorCodeLens-foreground); }
149
  svg circle.head { fill: var(--vscode-charts-green); }
150
  svg circle.wc { fill: var(--vscode-charts-blue); stroke: var(--vscode-charts-blue); stroke-width: 2; fill-opacity: .3; }
151
  .rows { position: relative; margin-left: ${graphW}px; height: ${changes.length * rowH}px; }
152
  .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; }
153
  .row:hover { background: var(--vscode-list-hoverBackground); }
154
  .row.wc { color: var(--vscode-descriptionForeground); font-style: italic; cursor: default; }
155
  .id { color: var(--vscode-charts-blue); font-family: var(--vscode-editor-font-family); }
156
  .subject { flex: 1; overflow: hidden; text-overflow: ellipsis; }
157
  .meta { color: var(--vscode-descriptionForeground); font-size: 12px; }
158
  .sig { margin-left: 4px; }
159
</style></head>
160
<body>
161
<h2>${escape(bookmark)} <span class="meta">${changes.length - 1} changes</span> <button id="refresh">Refresh</button></h2>
162
<div class="wrap">
163
  <svg width="${graphW}" height="${changes.length * rowH}">${edges.join("")}${nodes.join("")}</svg>
164
  <div class="rows">${rows}</div>
165
</div>
166
<script>
167
  const vscode = acquireVsCodeApi();
168
  document.querySelectorAll('.row:not(.wc)').forEach(r => r.addEventListener('click', () => vscode.postMessage({ type: 'open', hash: r.dataset.hash })));
169
  document.getElementById('refresh').addEventListener('click', () => vscode.postMessage({ type: 'refresh' }));
170
</script>
171
</body></html>`;
172
}
173
174
function escape(s: string): string {
175
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
176
}
177
178
export function repositoryLabel(repo: Repository): string {
179
  return repo.root.split("/").pop() ?? repo.root;
180
}