OXYGENHUB
◆ main oxygen-vscode / src / history.ts
8.9 KiB · last changed Oxygen colours, a Get started walkthrough, Create Identity (0.14.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 class="l${(lanes.get(d)! % 8) + 1}" 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
    const lane = `l${(lanes.get(c.hash)! % 8) + 1}`;
125
    nodes.push(`<circle class="${cls} ${lane}" cx="${x}" cy="${y}" r="${c.head ? 6 : 5}" />`);
126
  });
127
  const rows = changes
128
    .map((c, i) => {
129
      const subject = c.message.split("\n")[0] || (c.working_copy ? "(working copy)" : "(no description)");
130
      const sig = c.working_copy ? "" : c.signature?.status === "signed" || c.signature?.status === "sealed" ? (c.signature.revoked ? "⚠" : c.signature.status === "sealed" ? "◈" : "✔") : c.signature?.status === "invalid" ? "✖" : "·";
131
      const sigClass = c.signature?.revoked ? "sig-revoked" : `sig-${c.signature?.status ?? "none"}`;
132
      // Right-click: split, squash and rebase (webview/context menus in package.json).
133
      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 }))}'`;
134
      return `<div class="row${c.working_copy ? " wc" : ""}" style="top:${i * rowH}px" data-hash="${c.hash}" title="${escape(c.message)}"${ctx}>
135
        <span class="id">${c.change_id.slice(0, 8)}</span>
136
        <span class="subject">${escape(subject)}</span>
137
        <span class="meta">${escape(c.author)} · ${timeAgo(c.when_ms)} <span class="sig ${sigClass}" title="${escape(c.signature?.status ?? "")}">${sig}</span></span>
138
      </div>`;
139
    })
140
    .join("");
141
  return `<!DOCTYPE html>
142
<html><head><meta charset="utf-8">
143
<style>
144
  body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); margin: 0; padding: 8px 12px; }
145
  h2 { font-size: 13px; font-weight: 600; margin: 0 0 8px; display: flex; gap: 12px; align-items: center; }
146
  h2 button { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border: none; padding: 2px 8px; cursor: pointer; }
147
  .wrap { position: relative; }
148
  svg { position: absolute; left: 0; top: 0; }
149
  /* Lanes in the Oxygen mark's colours (themeable: oxygen.lane1…8). */
150
  ${[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `.l${n} { --lane: var(--vscode-oxygen-lane${n}); }`).join("\n  ")}
151
  svg path { fill: none; stroke: var(--lane); stroke-width: 1.6; opacity: .85; }
152
  svg circle.node { fill: var(--lane); }
153
  svg circle.head { fill: var(--vscode-oxygen-head); stroke: var(--vscode-editor-background); stroke-width: 1.5; filter: drop-shadow(0 0 3px var(--vscode-oxygen-head)); }
154
  svg circle.wc { fill: var(--vscode-oxygen-workingCopy); fill-opacity: .25; stroke: var(--vscode-oxygen-workingCopy); stroke-width: 2; transform-box: fill-box; transform-origin: center; animation: breathe 2.4s ease-in-out infinite; }
155
  @keyframes breathe { 0%, 100% { transform: scale(1); fill-opacity: .25; } 50% { transform: scale(1.25); fill-opacity: .55; } }
156
  @media (prefers-reduced-motion: reduce) { svg circle.wc { animation: none; } }
157
  h2::after { content: ""; flex: 1; height: 2px; margin-left: 6px; background: linear-gradient(90deg, var(--vscode-oxygen-lane1), var(--vscode-oxygen-lane2), transparent); border-radius: 1px; }
158
  .sig-signed { color: var(--vscode-oxygen-signed); }
159
  .sig-sealed { color: var(--vscode-oxygen-sealed); }
160
  .sig-invalid, .sig-revoked { color: var(--vscode-oxygen-invalid); }
161
  .row:not(.wc):hover .id { text-decoration: underline; }
162
  .rows { position: relative; margin-left: ${graphW}px; height: ${changes.length * rowH}px; }
163
  .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; }
164
  .row:hover { background: var(--vscode-list-hoverBackground); }
165
  .row.wc { color: var(--vscode-descriptionForeground); font-style: italic; cursor: default; }
166
  .id { color: var(--vscode-oxygen-lane1); font-family: var(--vscode-editor-font-family); }
167
  .subject { flex: 1; overflow: hidden; text-overflow: ellipsis; }
168
  .meta { color: var(--vscode-descriptionForeground); font-size: 12px; }
169
  .sig { margin-left: 4px; }
170
</style></head>
171
<body>
172
<h2>${escape(bookmark)} <span class="meta">${changes.length - 1} changes</span> <button id="refresh">Refresh</button></h2>
173
<div class="wrap">
174
  <svg width="${graphW}" height="${changes.length * rowH}">${edges.join("")}${nodes.join("")}</svg>
175
  <div class="rows">${rows}</div>
176
</div>
177
<script>
178
  const vscode = acquireVsCodeApi();
179
  document.querySelectorAll('.row:not(.wc)').forEach(r => r.addEventListener('click', () => vscode.postMessage({ type: 'open', hash: r.dataset.hash })));
180
  document.getElementById('refresh').addEventListener('click', () => vscode.postMessage({ type: 'refresh' }));
181
</script>
182
</body></html>`;
183
}
184
185
function escape(s: string): string {
186
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
187
}
188
189
export function repositoryLabel(repo: Repository): string {
190
  return repo.root.split("/").pop() ?? repo.root;
191
}