Commit box: amend mode, previous messages, signing indicator, and a warning before committing conflict markers; 0.9.0 44bfd5adfc9a
closes #2
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 16:08 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
44bfd5adfc9a3fd6a39086ab97363e33f29513a120b05c55a7f5641cd4b5d479- Depends on
- 25103dddd993 35d56b5803d9 9020cf612749 ab81294a9ba9 b4626290e660 bed495a96408 f6bda77ecb9f
- Depended on by
- dac6876e4d08 f532c4266f21 cdcffe39dd9f 1f7fc1e3ae4a
- In bookmarks
- main
- In tags
- v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
- Edits
- 28 atoms, 2 tree operations, 8 files
Browse the tree at this change
added src/commitbox.ts +90 −0
| 1 | + | // The commit box's logic (spec §5.1), kept free of the vscode module so it |
|
| 2 | + | // can be tested: signing state from `oxy identity show`, the placeholder |
|
| 3 | + | // that shows it, message history, and conflict marker detection. |
|
| 4 | + | ||
| 5 | + | export interface SigningInfo { |
|
| 6 | + | /** `Name <email>`, or empty without an identity. */ |
|
| 7 | + | who: string; |
|
| 8 | + | /** Whether the secret key is in the key directory. */ |
|
| 9 | + | secret: boolean; |
|
| 10 | + | /** `change`: every commit is signed; `seal`: changes are sealed at push. */ |
|
| 11 | + | mode: "change" | "seal"; |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | /** Parse `oxy --plain identity show`; `null` when no identity is configured. */ |
|
| 15 | + | export function parseIdentityShow(text: string): SigningInfo | null { |
|
| 16 | + | const head = text.match(/^#\s*identity\s+(.+)$/m); |
|
| 17 | + | if (!head) { |
|
| 18 | + | return null; |
|
| 19 | + | } |
|
| 20 | + | const secretLine = text.match(/^\s*secret\s+(.+)$/m)?.[1] ?? ""; |
|
| 21 | + | const signingLine = text.match(/^\s*signing\s+(.+)$/m)?.[1] ?? ""; |
|
| 22 | + | return { |
|
| 23 | + | who: head[1].trim(), |
|
| 24 | + | secret: /available/.test(secretLine), |
|
| 25 | + | mode: /core\.sign\s*=\s*change/.test(signingLine) ? "change" : "seal", |
|
| 26 | + | }; |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | /** A short name for the placeholder: the name without the address. */ |
|
| 30 | + | function shortName(who: string): string { |
|
| 31 | + | return who.replace(/\s*<[^>]*>\s*$/, "").trim() || who; |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /** How the next commit will be authenticated, for the commit box. */ |
|
| 35 | + | export function signingLabel(info: SigningInfo | null | undefined): string { |
|
| 36 | + | if (info === undefined) { |
|
| 37 | + | return ""; |
|
| 38 | + | } |
|
| 39 | + | if (info === null) { |
|
| 40 | + | return "⚠ unsigned: no identity (oxy identity create)"; |
|
| 41 | + | } |
|
| 42 | + | if (!info.secret) { |
|
| 43 | + | return `⚠ unsigned: the key for ${shortName(info.who)} is missing`; |
|
| 44 | + | } |
|
| 45 | + | return info.mode === "change" ? `✔ signed as ${shortName(info.who)}` : `◈ sealed at push as ${shortName(info.who)}`; |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /** The commit box placeholder for the current mode. */ |
|
| 49 | + | export function placeholder(opts: { amend: boolean; resolution: boolean; signing: string }): string { |
|
| 50 | + | const what = opts.amend ? "Amend the last change: new message, or empty to keep it" : opts.resolution ? "Describe the resolution" : "Message"; |
|
| 51 | + | const key = opts.amend ? "Ctrl+Enter to amend" : "Ctrl+Enter to commit"; |
|
| 52 | + | return `${what} (${key})${opts.signing ? ` · ${opts.signing}` : ""}`; |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | /** Recent commit messages, newest first, without duplicates. */ |
|
| 56 | + | export class MessageHistory { |
|
| 57 | + | static readonly LIMIT = 30; |
|
| 58 | + | private items: string[]; |
|
| 59 | + | ||
| 60 | + | constructor(items: readonly string[] = []) { |
|
| 61 | + | this.items = items.filter((m) => m.trim()).slice(0, MessageHistory.LIMIT); |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | add(message: string): void { |
|
| 65 | + | const m = message.trim(); |
|
| 66 | + | if (!m) { |
|
| 67 | + | return; |
|
| 68 | + | } |
|
| 69 | + | this.items = [m, ...this.items.filter((x) => x !== m)].slice(0, MessageHistory.LIMIT); |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | get entries(): readonly string[] { |
|
| 73 | + | return this.items; |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | /** |
|
| 78 | + | * Line numbers (1-based) that open or close a conflict: Oxygen's markers |
|
| 79 | + | * (`<<<<<<< oxy conflict …`) and Git's alike, since both begin with seven |
|
| 80 | + | * angle brackets. |
|
| 81 | + | */ |
|
| 82 | + | export function conflictMarkerLines(text: string): number[] { |
|
| 83 | + | const out: number[] = []; |
|
| 84 | + | text.split("\n").forEach((l, i) => { |
|
| 85 | + | if (/^(<{7}|>{7})( |\r?$)/.test(l)) { |
|
| 86 | + | out.push(i + 1); |
|
| 87 | + | } |
|
| 88 | + | }); |
|
| 89 | + | return out; |
|
| 90 | + | } |
added test/commitbox.test.mjs +49 −0
| 1 | + | import { test } from "node:test"; |
|
| 2 | + | import assert from "node:assert/strict"; |
|
| 3 | + | import { MessageHistory, conflictMarkerLines, parseIdentityShow, placeholder, signingLabel } from "../out/commitbox.mjs"; |
|
| 4 | + | ||
| 5 | + | const SIGNED = `# identity Andy McCall <[email protected]> |
|
| 6 | + | key f1add784 |
|
| 7 | + | created 2026-09-26 18:12:11Z (2 days ago) |
|
| 8 | + | secret * available |
|
| 9 | + | signing every change (core.sign = change) |
|
| 10 | + | `; |
|
| 11 | + | ||
| 12 | + | test("signing state from oxy identity show", () => { |
|
| 13 | + | const s = parseIdentityShow(SIGNED); |
|
| 14 | + | assert.deepEqual(s, { who: "Andy McCall <[email protected]>", secret: true, mode: "change" }); |
|
| 15 | + | assert.equal(signingLabel(s), "✔ signed as Andy McCall"); |
|
| 16 | + | const sealed = parseIdentityShow(SIGNED.replace("every change (core.sign = change)", "at push via seals (core.sign = seal)")); |
|
| 17 | + | assert.equal(signingLabel(sealed), "◈ sealed at push as Andy McCall"); |
|
| 18 | + | const keyless = parseIdentityShow(SIGNED.replace("* available", "x not found in the key directory")); |
|
| 19 | + | assert.match(signingLabel(keyless), /unsigned: the key for Andy McCall is missing/); |
|
| 20 | + | assert.equal(parseIdentityShow("No identity configured; run `oxy identity create`."), null); |
|
| 21 | + | assert.match(signingLabel(null), /unsigned: no identity/); |
|
| 22 | + | assert.equal(signingLabel(undefined), "", "not known yet"); |
|
| 23 | + | }); |
|
| 24 | + | ||
| 25 | + | test("placeholder follows the mode", () => { |
|
| 26 | + | assert.equal(placeholder({ amend: false, resolution: false, signing: "✔ signed as A" }), "Message (Ctrl+Enter to commit) · ✔ signed as A"); |
|
| 27 | + | assert.match(placeholder({ amend: true, resolution: false, signing: "" }), /^Amend the last change.*Ctrl\+Enter to amend\)$/); |
|
| 28 | + | assert.match(placeholder({ amend: false, resolution: true, signing: "" }), /^Describe the resolution/); |
|
| 29 | + | }); |
|
| 30 | + | ||
| 31 | + | test("message history is newest first, unique and bounded", () => { |
|
| 32 | + | const h = new MessageHistory(["b", "", "a"]); |
|
| 33 | + | h.add(" c "); |
|
| 34 | + | h.add("a"); |
|
| 35 | + | h.add(""); |
|
| 36 | + | assert.deepEqual(h.entries, ["a", "c", "b"]); |
|
| 37 | + | for (let i = 0; i < 40; i++) { |
|
| 38 | + | h.add(`m${i}`); |
|
| 39 | + | } |
|
| 40 | + | assert.equal(h.entries.length, MessageHistory.LIMIT); |
|
| 41 | + | assert.equal(h.entries[0], "m39"); |
|
| 42 | + | }); |
|
| 43 | + | ||
| 44 | + | test("conflict markers: Oxygen's and Git's, not look-alikes", () => { |
|
| 45 | + | const oxy = ['a', '<<<<<<< oxy conflict 1 of 1, side 1 of 2: change k7m2p9 "x"', "one", '======= oxy conflict 1 of 1, side 2 of 2: change q3x8w1 "y"', "two", ">>>>>>> oxy conflict 1 of 1 end", ""].join("\n"); |
|
| 46 | + | assert.deepEqual(conflictMarkerLines(oxy), [2, 6]); |
|
| 47 | + | assert.deepEqual(conflictMarkerLines("x\n<<<<<<< HEAD\ny\n=======\nz\n>>>>>>> feature\r\n"), [2, 6]); |
|
| 48 | + | assert.deepEqual(conflictMarkerLines("<<<<<<<< eight\n <<<<<<< indented\na <<<<<<< b\n"), []); |
|
| 49 | + | }); |
modified CHANGELOG.md +7 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.9.0 |
|
| 4 | + | ||
| 5 | + | - **Amend mode** (the pencil in the Source Control title bar): Ctrl+Enter amends the last change, and the box starts from its message. |
|
| 6 | + | - **Previous Commit Messages…** puts a recent message back in the box. Messages are remembered per repository as you commit. |
|
| 7 | + | - **Signing indicator**: the box says whether the next commit will be signed, sealed at push, or unsigned (no identity, or its key missing). |
|
| 8 | + | - **Conflict marker warning**: committing or amending files that still hold conflict markers (Oxygen's or Git's) asks first, and can open the first one. |
|
| 9 | + | ||
| 3 | 10 | ## 0.8.0 |
|
| 4 | 11 | ||
| 5 | 12 | - The History graph rewrites history: |
|
| … | 49 unchanged lines |
modified README.md +3 −0
| … | 36 unchanged lines |
||
| 37 | 37 | - **Status bar**: bookmark, working copy and state; click to switch, create, |
|
| 38 | 38 | merge or delete bookmarks; a sync counter with Sync, Fetch, Pull, Push and |
|
| 39 | 39 | Push Bookmark. |
|
| 40 | + | - **Commit box**: amend mode, previous messages, a note of whether the next |
|
| 41 | + | commit is signed, sealed at push or unsigned, and a warning before |
|
| 42 | + | committing leftover conflict markers. |
|
| 40 | 43 | - **Views**: Oxygen Operations (undo to any point), Oxygen Bisect |
|
| 41 | 44 | (start, good, bad, skip, run, reset), Oxygen Trust (policy, signatures), |
|
| 42 | 45 | and a History graph with per-change actions (diff, check out, describe, |
|
| … | 15 unchanged lines |
modified package.json +35 −2
| … | 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.8.0", |
|
| 5 | + | "version": "0.9.0", |
|
| 6 | 6 | "publisher": "oxygen", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygen.png", |
|
| … | 301 unchanged lines |
||
| 310 | 310 | "title": "Rebase onto\u2026", |
|
| 311 | 311 | "category": "Oxygen", |
|
| 312 | 312 | "icon": "$(git-pull-request-go-to-changes)" |
|
| 313 | + | }, |
|
| 314 | + | { |
|
| 315 | + | "command": "oxygen.amendMode", |
|
| 316 | + | "title": "Amend Mode", |
|
| 317 | + | "category": "Oxygen", |
|
| 318 | + | "icon": "$(edit)" |
|
| 319 | + | }, |
|
| 320 | + | { |
|
| 321 | + | "command": "oxygen.leaveAmendMode", |
|
| 322 | + | "title": "Leave Amend Mode", |
|
| 323 | + | "category": "Oxygen", |
|
| 324 | + | "icon": "$(close)" |
|
| 325 | + | }, |
|
| 326 | + | { |
|
| 327 | + | "command": "oxygen.messageHistory", |
|
| 328 | + | "title": "Previous Commit Messages\u2026", |
|
| 329 | + | "category": "Oxygen", |
|
| 330 | + | "icon": "$(history)" |
|
| 313 | 331 | } |
|
| 314 | 332 | ], |
|
| 315 | 333 | "views": { |
|
| … | 149 unchanged lines |
||
| 465 | 483 | "command": "oxygen.openOnRemote", |
|
| 466 | 484 | "when": "scmProvider == oxygen", |
|
| 467 | 485 | "group": "navigation@9" |
|
| 486 | + | }, |
|
| 487 | + | { |
|
| 488 | + | "command": "oxygen.amendMode", |
|
| 489 | + | "group": "navigation@4", |
|
| 490 | + | "when": "scmProvider == oxygen && !oxygen.amending" |
|
| 491 | + | }, |
|
| 492 | + | { |
|
| 493 | + | "command": "oxygen.leaveAmendMode", |
|
| 494 | + | "group": "navigation@4", |
|
| 495 | + | "when": "scmProvider == oxygen && oxygen.amending" |
|
| 496 | + | }, |
|
| 497 | + | { |
|
| 498 | + | "command": "oxygen.messageHistory", |
|
| 499 | + | "group": "2_commit@4", |
|
| 500 | + | "when": "scmProvider == oxygen" |
|
| 468 | 501 | } |
|
| 469 | 502 | ], |
|
| 470 | 503 | "editor/title": [ |
|
| … | 244 unchanged lines |
||
| 715 | 748 | "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap", |
|
| 716 | 749 | "watch": "npm run build -- --watch", |
|
| 717 | 750 | "typecheck": "tsc --noEmit -p .", |
|
| 718 | - | "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 && esbuild src/markers.ts --bundle --platform=node --format=esm --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --outfile=out/api.mjs", |
|
| 751 | + | "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 && esbuild src/markers.ts --bundle --platform=node --format=esm --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --outfile=out/api.mjs && esbuild src/commitbox.ts --bundle --platform=node --format=esm --outfile=out/commitbox.mjs", |
|
| 719 | 752 | "test": "node --test \"test/**/*.test.mjs\"", |
|
| 720 | 753 | "check": "npm run typecheck && npm run build && npm test", |
|
| 721 | 754 | "package": "vsce package --no-dependencies" |
|
| … | 9 unchanged lines |
modified src/extension.ts +177 −4
| … | 2 unchanged lines |
||
| 3 | 3 | ||
| 4 | 4 | import * as vscode from "vscode"; |
|
| 5 | 5 | import { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, pickProvider, repositoryFor as rootFor } from "./api"; |
|
| 6 | - | import { basename } from "node:path"; |
|
| 6 | + | import { basename, join } from "node:path"; |
|
| 7 | 7 | import { Oxy, OxyError, ShowInfo, findBinary } from "./oxy"; |
|
| 8 | 8 | import { Repository, OxyResourceState, SubrepoState } from "./repository"; |
|
| 9 | 9 | import { BisectProvider, TrustProvider } from "./panels"; |
|
| … | 2 unchanged lines |
||
| 12 | 12 | import { OperationItem, OperationsProvider, timeAgo } from "./operations"; |
|
| 13 | 13 | import { BlameController } from "./blame"; |
|
| 14 | 14 | import { HistoryPanel } from "./history"; |
|
| 15 | + | import { MessageHistory, conflictMarkerLines, placeholder, signingLabel } from "./commitbox"; |
|
| 16 | + | import { readFile, stat } from "node:fs/promises"; |
|
| 15 | 17 | import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts"; |
|
| 16 | 18 | ||
| 17 | 19 | const MIN_VERSION = { format: 2, protocol: 1 }; |
|
| … | 10 unchanged lines |
||
| 28 | 30 | const repositories = new Map<string, Repository>(); |
|
| 29 | 31 | ||
| 30 | 32 | export async function activate(context: vscode.ExtensionContext): Promise<OxygenAPI | undefined> { |
|
| 33 | + | extensionContext = context; |
|
| 31 | 34 | output = vscode.window.createOutputChannel("Oxygen"); |
|
| 32 | 35 | context.subscriptions.push(output); |
|
| 33 | 36 | output.appendLine(`Oxygen extension ${context.extension.packageJSON.version} activating; folders: ${(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath).join(", ") || "(none)"}`); |
|
| … | 47 unchanged lines |
||
| 81 | 84 | vscode.commands.registerCommand("oxygen.history.split", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "split")), |
|
| 82 | 85 | vscode.commands.registerCommand("oxygen.history.squash", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "squash")), |
|
| 83 | 86 | vscode.commands.registerCommand("oxygen.history.rebase", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "rebase")), |
|
| 87 | + | vscode.commands.registerCommand("oxygen.amendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, true)), |
|
| 88 | + | vscode.commands.registerCommand("oxygen.leaveAmendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, false)), |
|
| 89 | + | vscode.commands.registerCommand("oxygen.messageHistory", (arg?: Repository | vscode.SourceControl) => messageHistory(arg)), |
|
| 84 | 90 | vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)), |
|
| 85 | 91 | vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)), |
|
| 86 | 92 | vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()), |
|
| … | 186 unchanged lines |
||
| 273 | 279 | bisectView.refresh(); |
|
| 274 | 280 | trustView.refresh(); |
|
| 275 | 281 | // The commit action reads as a resolution while conflicts are resolved but uncommitted. |
|
| 276 | - | const resolved = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true); |
|
| 277 | - | repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: resolved ? "Commit resolution" : "Commit", arguments: [repo] }; |
|
| 278 | - | repo.sourceControl.inputBox.placeholder = resolved ? "Describe the resolution (Ctrl+Enter to commit)" : "Message (Ctrl+Enter to commit)"; |
|
| 282 | + | box(repo).resolution = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true); |
|
| 283 | + | void refreshSigning(repo); |
|
| 284 | + | updateBox(repo); |
|
| 279 | 285 | }); |
|
| 280 | 286 | repositories.set(root, repo); |
|
| 281 | 287 | repo.onDidChangeStatus(() => onDidChangeStatusEmitter.fire(handleOf(repo))); |
|
| … | 39 unchanged lines |
||
| 321 | 327 | const repo = repositoryFor(arg); |
|
| 322 | 328 | if (!repo) { |
|
| 323 | 329 | void vscode.window.showErrorMessage("Oxygen: no repository for the active file."); |
|
| 330 | + | return; |
|
| 331 | + | } |
|
| 332 | + | if (box(repo).amend) { |
|
| 333 | + | await amend(repo); |
|
| 324 | 334 | return; |
|
| 325 | 335 | } |
|
| 326 | 336 | let message = repo.sourceControl.inputBox.value.trim(); |
|
| … | 3 unchanged lines |
||
| 330 | 340 | if (!message) { |
|
| 331 | 341 | return; |
|
| 332 | 342 | } |
|
| 343 | + | if (!(await markersOk(repo))) { |
|
| 344 | + | return; |
|
| 345 | + | } |
|
| 333 | 346 | try { |
|
| 334 | 347 | await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message)); |
|
| 348 | + | remember(repo, message); |
|
| 335 | 349 | repo.sourceControl.inputBox.value = ""; |
|
| 336 | 350 | await repo.refresh(); |
|
| 337 | 351 | } catch (e) { |
|
| … | 45 unchanged lines |
||
| 383 | 397 | const repo = states[0].repository; |
|
| 384 | 398 | const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`); |
|
| 385 | 399 | if (!message) { |
|
| 400 | + | return; |
|
| 401 | + | } |
|
| 402 | + | if (!(await markersOk(repo, states.map((s) => s.path)))) { |
|
| 386 | 403 | return; |
|
| 387 | 404 | } |
|
| 388 | 405 | try { |
|
| 389 | 406 | await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path))); |
|
| 407 | + | remember(repo, message); |
|
| 390 | 408 | repo.sourceControl.inputBox.value = ""; |
|
| 391 | 409 | await repo.refresh(); |
|
| 392 | 410 | } catch (e) { |
|
| … | 36 unchanged lines |
||
| 429 | 447 | } |
|
| 430 | 448 | try { |
|
| 431 | 449 | await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index)); |
|
| 450 | + | remember(repo, message); |
|
| 432 | 451 | repo.sourceControl.inputBox.value = ""; |
|
| 433 | 452 | await repo.refresh(); |
|
| 434 | 453 | } catch (e) { |
|
| … | 7 unchanged lines |
||
| 442 | 461 | return; |
|
| 443 | 462 | } |
|
| 444 | 463 | const message = repo.sourceControl.inputBox.value.trim(); |
|
| 464 | + | if (!(await markersOk(repo))) { |
|
| 465 | + | return; |
|
| 466 | + | } |
|
| 445 | 467 | try { |
|
| 446 | 468 | await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined)); |
|
| 469 | + | remember(repo, message); |
|
| 447 | 470 | repo.sourceControl.inputBox.value = ""; |
|
| 471 | + | setAmend(repo, false); |
|
| 448 | 472 | await repo.refresh(); |
|
| 449 | 473 | } catch (e) { |
|
| 450 | 474 | showError(e); |
|
| … | 438 unchanged lines |
||
| 889 | 913 | } catch (e) { |
|
| 890 | 914 | showError(e); |
|
| 891 | 915 | } |
|
| 916 | + | } |
|
| 917 | + | ||
| 918 | + | // ------------------------------------------------------ commit box (V7) |
|
| 919 | + | ||
| 920 | + | interface BoxState { |
|
| 921 | + | amend: boolean; |
|
| 922 | + | resolution: boolean; |
|
| 923 | + | signing: string; |
|
| 924 | + | checkedMs: number; |
|
| 925 | + | } |
|
| 926 | + | ||
| 927 | + | const boxes = new Map<string, BoxState>(); |
|
| 928 | + | let extensionContext: vscode.ExtensionContext | undefined; |
|
| 929 | + | ||
| 930 | + | function box(repo: Repository): BoxState { |
|
| 931 | + | let b = boxes.get(repo.root); |
|
| 932 | + | if (!b) { |
|
| 933 | + | b = { amend: false, resolution: false, signing: "", checkedMs: 0 }; |
|
| 934 | + | boxes.set(repo.root, b); |
|
| 935 | + | } |
|
| 936 | + | return b; |
|
| 937 | + | } |
|
| 938 | + | ||
| 939 | + | /** Placeholder and the Ctrl+Enter action follow amend mode, resolution and signing. */ |
|
| 940 | + | function updateBox(repo: Repository): void { |
|
| 941 | + | const b = box(repo); |
|
| 942 | + | repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: b.amend ? "Amend" : b.resolution ? "Commit resolution" : "Commit", arguments: [repo] }; |
|
| 943 | + | repo.sourceControl.inputBox.placeholder = placeholder(b); |
|
| 944 | + | } |
|
| 945 | + | ||
| 946 | + | /** Re-read how commits will be signed, at most every 30 seconds. */ |
|
| 947 | + | async function refreshSigning(repo: Repository, force = false): Promise<void> { |
|
| 948 | + | const b = box(repo); |
|
| 949 | + | if (!force && Date.now() - b.checkedMs < 30_000) { |
|
| 950 | + | return; |
|
| 951 | + | } |
|
| 952 | + | b.checkedMs = Date.now(); |
|
| 953 | + | try { |
|
| 954 | + | b.signing = signingLabel(await repo.oxy.identity(repo.root)); |
|
| 955 | + | } catch { |
|
| 956 | + | b.signing = ""; |
|
| 957 | + | } |
|
| 958 | + | updateBox(repo); |
|
| 959 | + | } |
|
| 960 | + | ||
| 961 | + | function setAmend(repo: Repository, on: boolean): void { |
|
| 962 | + | box(repo).amend = on; |
|
| 963 | + | void vscode.commands.executeCommand("setContext", "oxygen.amending", [...boxes.values()].some((b) => b.amend)); |
|
| 964 | + | updateBox(repo); |
|
| 965 | + | } |
|
| 966 | + | ||
| 967 | + | async function toggleAmend(arg: Repository | vscode.SourceControl | undefined, on: boolean): Promise<void> { |
|
| 968 | + | const repo = repositoryFor(arg); |
|
| 969 | + | if (!repo) { |
|
| 970 | + | return; |
|
| 971 | + | } |
|
| 972 | + | setAmend(repo, on); |
|
| 973 | + | if (on && !repo.sourceControl.inputBox.value.trim()) { |
|
| 974 | + | // Start from the message being amended. |
|
| 975 | + | try { |
|
| 976 | + | const last = (await repo.oxy.log(repo.root, 2)).changes.find((c) => !c.working_copy); |
|
| 977 | + | if (last) { |
|
| 978 | + | repo.sourceControl.inputBox.value = last.message; |
|
| 979 | + | } |
|
| 980 | + | } catch { |
|
| 981 | + | // An empty box keeps the message anyway. |
|
| 982 | + | } |
|
| 983 | + | } |
|
| 984 | + | } |
|
| 985 | + | ||
| 986 | + | function historyOf(repo: Repository): MessageHistory { |
|
| 987 | + | return new MessageHistory(extensionContext?.workspaceState.get<string[]>(`oxygen.messages:${repo.root}`) ?? []); |
|
| 988 | + | } |
|
| 989 | + | ||
| 990 | + | function remember(repo: Repository, message: string): void { |
|
| 991 | + | const h = historyOf(repo); |
|
| 992 | + | h.add(message); |
|
| 993 | + | void extensionContext?.workspaceState.update(`oxygen.messages:${repo.root}`, h.entries); |
|
| 994 | + | } |
|
| 995 | + | ||
| 996 | + | async function messageHistory(arg: Repository | vscode.SourceControl | undefined): Promise<void> { |
|
| 997 | + | const repo = repositoryFor(arg); |
|
| 998 | + | if (!repo) { |
|
| 999 | + | return; |
|
| 1000 | + | } |
|
| 1001 | + | const entries = historyOf(repo).entries; |
|
| 1002 | + | if (entries.length === 0) { |
|
| 1003 | + | void vscode.window.showInformationMessage("Oxygen: no messages yet; they are remembered as you commit."); |
|
| 1004 | + | return; |
|
| 1005 | + | } |
|
| 1006 | + | const pick = await vscode.window.showQuickPick( |
|
| 1007 | + | entries.map((m) => ({ label: m.split("\n")[0], detail: m.includes("\n") ? m.split("\n").slice(1).join(" ").trim().slice(0, 160) : undefined, message: m })), |
|
| 1008 | + | { title: "Previous commit messages", placeHolder: "Put one back in the commit box", matchOnDetail: true }, |
|
| 1009 | + | ); |
|
| 1010 | + | if (pick) { |
|
| 1011 | + | repo.sourceControl.inputBox.value = pick.message; |
|
| 1012 | + | } |
|
| 1013 | + | } |
|
| 1014 | + | ||
| 1015 | + | /** |
|
| 1016 | + | * Warn before committing files that still hold conflict markers: those oxy |
|
| 1017 | + | * reports (markers left in a conflicted file) and any text file whose lines |
|
| 1018 | + | * open or close a conflict. `paths` limits the check to what is committed. |
|
| 1019 | + | */ |
|
| 1020 | + | async function markersOk(repo: Repository, paths?: string[]): Promise<boolean> { |
|
| 1021 | + | const status = repo.status; |
|
| 1022 | + | if (!status) { |
|
| 1023 | + | return true; |
|
| 1024 | + | } |
|
| 1025 | + | const found: { path: string; line?: number }[] = []; |
|
| 1026 | + | for (const p of status.paths) { |
|
| 1027 | + | if (p.status === "Removed" || (paths && !paths.includes(p.path))) { |
|
| 1028 | + | continue; |
|
| 1029 | + | } |
|
| 1030 | + | if (p.status === "ConflictEdited") { |
|
| 1031 | + | found.push({ path: p.path }); |
|
| 1032 | + | continue; |
|
| 1033 | + | } |
|
| 1034 | + | try { |
|
| 1035 | + | const file = join(repo.root, p.path); |
|
| 1036 | + | if ((await stat(file)).size > 4 * 1024 * 1024) { |
|
| 1037 | + | continue; |
|
| 1038 | + | } |
|
| 1039 | + | const text = (await readFile(file)).toString("utf8"); |
|
| 1040 | + | const lines = conflictMarkerLines(text); |
|
| 1041 | + | if (lines.length > 0) { |
|
| 1042 | + | found.push({ path: p.path, line: lines[0] }); |
|
| 1043 | + | } |
|
| 1044 | + | } catch { |
|
| 1045 | + | // Unreadable or gone: nothing to check. |
|
| 1046 | + | } |
|
| 1047 | + | } |
|
| 1048 | + | if (found.length === 0) { |
|
| 1049 | + | return true; |
|
| 1050 | + | } |
|
| 1051 | + | const list = found.slice(0, 5).map((f) => (f.line ? `${f.path} (line ${f.line})` : f.path)).join(", ") + (found.length > 5 ? ` and ${found.length - 5} more` : ""); |
|
| 1052 | + | const pick = await vscode.window.showWarningMessage( |
|
| 1053 | + | `Conflict markers remain in ${list}. Commit anyway?`, |
|
| 1054 | + | { modal: true, detail: "Committing records the marker lines as ordinary text." }, |
|
| 1055 | + | "Commit anyway", |
|
| 1056 | + | `Open ${basename(found[0].path)}`, |
|
| 1057 | + | ); |
|
| 1058 | + | if (pick?.startsWith("Open ")) { |
|
| 1059 | + | const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(join(repo.root, found[0].path))); |
|
| 1060 | + | const line = Math.max(0, (found[0].line ?? 1) - 1); |
|
| 1061 | + | await vscode.window.showTextDocument(doc, { selection: new vscode.Range(line, 0, line, 0) }); |
|
| 1062 | + | return false; |
|
| 1063 | + | } |
|
| 1064 | + | return pick === "Commit anyway"; |
|
| 892 | 1065 | } |
|
| 893 | 1066 | ||
| 894 | 1067 | async function fileHistory(uri: vscode.Uri | undefined): Promise<void> { |
|
| … | 353 unchanged lines |
modified src/oxy.ts +6 −0
| … | 2 unchanged lines |
||
| 3 | 3 | // the vscode API so it can be tested with plain Node. |
|
| 4 | 4 | ||
| 5 | 5 | import { execFile } from "node:child_process"; |
|
| 6 | + | import { SigningInfo, parseIdentityShow } from "./commitbox"; |
|
| 6 | 7 | import { existsSync } from "node:fs"; |
|
| 7 | 8 | import { homedir } from "node:os"; |
|
| 8 | 9 | import { join } from "node:path"; |
|
| … | 236 unchanged lines |
||
| 245 | 246 | ||
| 246 | 247 | abandon(root: string, change: string): Promise<string> { |
|
| 247 | 248 | return this.check(root, ["abandon", change]).then((r) => r.stdout.toString("utf8").trim()); |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | /** The configured identity and how commits are signed; `null` without one. */ |
|
| 252 | + | identity(root: string): Promise<SigningInfo | null> { |
|
| 253 | + | return this.check(root, ["identity", "show"]).then((r) => parseIdentityShow(r.stdout.toString("utf8"))); |
|
| 248 | 254 | } |
|
| 249 | 255 | ||
| 250 | 256 | /** Split a head: the edits to `paths` become a separate first change. Returns oxy's summary line. */ |
|
| … | 242 unchanged lines |
modified test/oxy.test.mjs +15 −0
| … | 180 unchanged lines |
||
| 181 | 181 | // Rebase onto a state that already has the change is refused, with oxy's reason. |
|
| 182 | 182 | await assert.rejects(oxy.rebase(root, `#${after.hash.slice(0, 12)}`, ["main"]), /already part of the target state/); |
|
| 183 | 183 | }); |
|
| 184 | + | ||
| 185 | + | test("identity reports how commits are signed", async () => { |
|
| 186 | + | const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-id-")); |
|
| 187 | + | const home = join(dir, "home"); |
|
| 188 | + | mkdirSync(home); |
|
| 189 | + | process.env.OXY_HOME = home; |
|
| 190 | + | await oxy.check(dir, ["init", "repo"]); |
|
| 191 | + | const root = join(dir, "repo"); |
|
| 192 | + | assert.equal(await oxy.identity(root), null, "none yet"); |
|
| 193 | + | await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected]"]); |
|
| 194 | + | const id = await oxy.identity(root); |
|
| 195 | + | assert.equal(id?.who, "Test <[email protected]>"); |
|
| 196 | + | assert.equal(id?.secret, true); |
|
| 197 | + | assert.ok(id?.mode === "change" || id?.mode === "seal"); |
|
| 198 | + | }); |