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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
|
| 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 |