Milestone 0.5: conflict editor CodeLens over oxy markers, keep-this-candidate for tree conflicts, mark resolved, commit resolution label 25103dddd993
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 15:56 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
25103dddd993ec7d8ef86bc66b9a9737c1369f9b73e54effa9e820b46c84f321- Depends on
- 35d56b5803d9 ab81294a9ba9 bed495a96408
- Depended on by
- dac6876e4d08 196392053c9f f532c4266f21 44bfd5adfc9a f6bda77ecb9f d2c9a5843004
- In bookmarks
- main
- In tags
- v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
- Edits
- 20 atoms, 3 tree operations, 7 files
Browse the tree at this change
added src/conflicts.ts +97 −0
| 1 | + | // The conflict editor (spec §5.7): CodeLens actions over each conflict |
|
| 2 | + | // region, keep-this-candidate for tree conflicts, and mark-resolved. |
|
| 3 | + | ||
| 4 | + | import * as vscode from "vscode"; |
|
| 5 | + | import { basename, dirname, join } from "node:path"; |
|
| 6 | + | import { promises as fs } from "node:fs"; |
|
| 7 | + | import { ConflictRegion, candidateBase, parseConflicts, resolveRegion } from "./markers"; |
|
| 8 | + | import { Repository } from "./repository"; |
|
| 9 | + | ||
| 10 | + | export class ConflictCodeLensProvider implements vscode.CodeLensProvider { |
|
| 11 | + | private readonly emitter = new vscode.EventEmitter<void>(); |
|
| 12 | + | readonly onDidChangeCodeLenses = this.emitter.event; |
|
| 13 | + | ||
| 14 | + | constructor(private readonly repositories: () => Repository[]) {} |
|
| 15 | + | ||
| 16 | + | refresh(): void { |
|
| 17 | + | this.emitter.fire(); |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { |
|
| 21 | + | if (document.uri.scheme !== "file" || !this.repositories().some((r) => r.relativePath(document.uri) !== undefined)) { |
|
| 22 | + | return []; |
|
| 23 | + | } |
|
| 24 | + | const text = document.getText(); |
|
| 25 | + | if (!text.includes("<<<<<<< oxy conflict")) { |
|
| 26 | + | return []; |
|
| 27 | + | } |
|
| 28 | + | const regions = parseConflicts(text.split(/\r?\n/)); |
|
| 29 | + | const lenses: vscode.CodeLens[] = []; |
|
| 30 | + | regions.forEach((region, index) => { |
|
| 31 | + | const range = new vscode.Range(region.start, 0, region.start, 0); |
|
| 32 | + | for (const side of region.sides) { |
|
| 33 | + | const title = side.deleted ? `Accept deletion (${side.label.replace("deleted by change ", "")})` : `Accept side ${side.number} (${side.label})`; |
|
| 34 | + | lenses.push(new vscode.CodeLens(range, { title, command: "oxygen.acceptSides", arguments: [document.uri, index, [side.number]] })); |
|
| 35 | + | } |
|
| 36 | + | if (region.sides.length > 1 && !region.sides.every((s) => s.deleted)) { |
|
| 37 | + | lenses.push(new vscode.CodeLens(range, { title: "Accept all sides", command: "oxygen.acceptSides", arguments: [document.uri, index, region.sides.map((s) => s.number)] })); |
|
| 38 | + | } |
|
| 39 | + | lenses.push(new vscode.CodeLens(range, { title: `Conflict ${region.number} of ${region.total}`, command: "" })); |
|
| 40 | + | }); |
|
| 41 | + | return lenses; |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /** Replace conflict region `index` of the document with the kept sides. */ |
|
| 46 | + | export async function acceptSides(uri: vscode.Uri, index: number, keep: number[]): Promise<void> { |
|
| 47 | + | const document = await vscode.workspace.openTextDocument(uri); |
|
| 48 | + | const lines = document.getText().split(/\r?\n/); |
|
| 49 | + | const regions = parseConflicts(lines); |
|
| 50 | + | const region: ConflictRegion | undefined = regions[index]; |
|
| 51 | + | if (!region) { |
|
| 52 | + | return; |
|
| 53 | + | } |
|
| 54 | + | const replacement = resolveRegion(lines, region, keep); |
|
| 55 | + | const edit = new vscode.WorkspaceEdit(); |
|
| 56 | + | const eol = document.eol === vscode.EndOfLine.CRLF ? "\r\n" : "\n"; |
|
| 57 | + | const range = new vscode.Range(region.start, 0, region.end + 1, 0); |
|
| 58 | + | edit.replace(uri, range, replacement.length ? replacement.join(eol) + eol : ""); |
|
| 59 | + | await vscode.workspace.applyEdit(edit); |
|
| 60 | + | await document.save(); |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /** Keep one `.oxy-conflict-N` candidate under the real name; the others go. */ |
|
| 64 | + | export async function keepCandidate(repo: Repository, candidatePath: string): Promise<void> { |
|
| 65 | + | const base = candidateBase(candidatePath); |
|
| 66 | + | if (!base) { |
|
| 67 | + | return; |
|
| 68 | + | } |
|
| 69 | + | const dir = join(repo.root, dirname(candidatePath)); |
|
| 70 | + | const name = basename(base); |
|
| 71 | + | const entries = await fs.readdir(dir); |
|
| 72 | + | for (const e of entries) { |
|
| 73 | + | if (e !== basename(candidatePath) && candidateBase(e) === name) { |
|
| 74 | + | await fs.rm(join(dir, e), { recursive: true, force: true }); |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | await fs.rename(join(repo.root, candidatePath), join(repo.root, base)); |
|
| 78 | + | await repo.refresh(); |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | export async function markResolved(repo: Repository): Promise<void> { |
|
| 82 | + | const r = await repo.oxy.run(repo.root, ["resolve", "--json"]); |
|
| 83 | + | await repo.refresh(); |
|
| 84 | + | const status = repo.status; |
|
| 85 | + | if (!status || !status.conflicted) { |
|
| 86 | + | void vscode.window.showInformationMessage("Oxygen: no conflicts remain."); |
|
| 87 | + | return; |
|
| 88 | + | } |
|
| 89 | + | const unresolved = status.conflicts.filter((c) => c.resolved !== true); |
|
| 90 | + | if (unresolved.length === 0) { |
|
| 91 | + | void vscode.window.showInformationMessage("Oxygen: every conflict is resolved; commit to record the resolution."); |
|
| 92 | + | } else { |
|
| 93 | + | const names = unresolved.map((c) => `${c.path ?? c.kind}${c.resolved === "markers" ? " (markers remain)" : ""}`).join(", "); |
|
| 94 | + | void vscode.window.showWarningMessage(`Oxygen: still to resolve: ${names}`); |
|
| 95 | + | } |
|
| 96 | + | void r; |
|
| 97 | + | } |
added src/markers.ts +98 −0
| 1 | + | // Parser for oxy conflict markers (spec §9.5). No vscode imports, so it is |
|
| 2 | + | // tested with plain Node. |
|
| 3 | + | // |
|
| 4 | + | // <<<<<<< oxy conflict 1 of 2, side 1 of 2: change k7m2p9 "Add retry loop" |
|
| 5 | + | // ... |
|
| 6 | + | // ======= oxy conflict 1 of 2, side 2 of 2: change q3x8w1 "Refactor client" |
|
| 7 | + | // ... |
|
| 8 | + | // >>>>>>> oxy conflict 1 of 2 end |
|
| 9 | + | // |
|
| 10 | + | // A delete/insert conflict's first side reads `deleted by change X` and is |
|
| 11 | + | // followed by a `-------` line and the deleted context. |
|
| 12 | + | ||
| 13 | + | export interface ConflictSide { |
|
| 14 | + | /** 1-based side number. */ |
|
| 15 | + | number: number; |
|
| 16 | + | /** `k7m2p9 "Add retry loop"` or `deleted by change k7m2p9`. */ |
|
| 17 | + | label: string; |
|
| 18 | + | deleted: boolean; |
|
| 19 | + | /** Line index of the side's header. */ |
|
| 20 | + | header: number; |
|
| 21 | + | /** Content lines (exclusive end). */ |
|
| 22 | + | start: number; |
|
| 23 | + | end: number; |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | export interface ConflictRegion { |
|
| 27 | + | number: number; |
|
| 28 | + | total: number; |
|
| 29 | + | /** Line index of `<<<<<<<`. */ |
|
| 30 | + | start: number; |
|
| 31 | + | /** Line index of `>>>>>>>`. */ |
|
| 32 | + | end: number; |
|
| 33 | + | sides: ConflictSide[]; |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | const OPEN = /^<<<<<<< oxy conflict (\d+) of (\d+), side (\d+) of (\d+): (.*)$/; |
|
| 37 | + | const NEXT = /^======= oxy conflict (\d+) of (\d+), side (\d+) of (\d+): (.*)$/; |
|
| 38 | + | const CLOSE = /^>>>>>>> oxy conflict (\d+) of (\d+) end$/; |
|
| 39 | + | ||
| 40 | + | export function parseConflicts(lines: string[]): ConflictRegion[] { |
|
| 41 | + | const regions: ConflictRegion[] = []; |
|
| 42 | + | let current: ConflictRegion | undefined; |
|
| 43 | + | let side: ConflictSide | undefined; |
|
| 44 | + | for (let i = 0; i < lines.length; i++) { |
|
| 45 | + | const line = lines[i]; |
|
| 46 | + | const open = OPEN.exec(line); |
|
| 47 | + | if (open && !current) { |
|
| 48 | + | current = { number: Number(open[1]), total: Number(open[2]), start: i, end: -1, sides: [] }; |
|
| 49 | + | side = newSide(Number(open[3]), open[5], i); |
|
| 50 | + | if (side.deleted && lines[i + 1] === "-------") { |
|
| 51 | + | side.start = i + 2; |
|
| 52 | + | i += 1; |
|
| 53 | + | } |
|
| 54 | + | current.sides.push(side); |
|
| 55 | + | continue; |
|
| 56 | + | } |
|
| 57 | + | const next = current ? NEXT.exec(line) : null; |
|
| 58 | + | if (next && current && side) { |
|
| 59 | + | side.end = i; |
|
| 60 | + | side = newSide(Number(next[3]), next[5], i); |
|
| 61 | + | current.sides.push(side); |
|
| 62 | + | continue; |
|
| 63 | + | } |
|
| 64 | + | const close = current ? CLOSE.exec(line) : null; |
|
| 65 | + | if (close && current && side) { |
|
| 66 | + | side.end = i; |
|
| 67 | + | current.end = i; |
|
| 68 | + | regions.push(current); |
|
| 69 | + | current = undefined; |
|
| 70 | + | side = undefined; |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | return regions; |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | function newSide(number: number, label: string, header: number): ConflictSide { |
|
| 77 | + | const deleted = label.startsWith("deleted by change "); |
|
| 78 | + | return { number, label: deleted ? label : label.replace(/^change /, ""), deleted, header, start: header + 1, end: header + 1 }; |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /** The lines that replace a region when `keep` (side numbers) are kept, in order. */ |
|
| 82 | + | export function resolveRegion(lines: string[], region: ConflictRegion, keep: number[]): string[] { |
|
| 83 | + | const out: string[] = []; |
|
| 84 | + | for (const n of keep) { |
|
| 85 | + | const side = region.sides.find((s) => s.number === n); |
|
| 86 | + | if (!side || side.deleted) { |
|
| 87 | + | continue; |
|
| 88 | + | } |
|
| 89 | + | out.push(...lines.slice(side.start, side.end)); |
|
| 90 | + | } |
|
| 91 | + | return out; |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | /** A conflict candidate file's real name, or undefined. */ |
|
| 95 | + | export function candidateBase(path: string): string | undefined { |
|
| 96 | + | const m = /^(.*)\.oxy-conflict-\d+$/.exec(path); |
|
| 97 | + | return m ? m[1] : undefined; |
|
| 98 | + | } |
added test/markers.test.mjs +51 −0
| 1 | + | import { test } from "node:test"; |
|
| 2 | + | import assert from "node:assert/strict"; |
|
| 3 | + | import { parseConflicts, resolveRegion, candidateBase } from "../out/markers.mjs"; |
|
| 4 | + | ||
| 5 | + | test("parses order conflicts with several sides", () => { |
|
| 6 | + | const lines = [ |
|
| 7 | + | "a", |
|
| 8 | + | '<<<<<<< oxy conflict 1 of 2, side 1 of 3: change 872724ae "Doing"', |
|
| 9 | + | "How are you doing?", |
|
| 10 | + | '======= oxy conflict 1 of 2, side 2 of 3: change 194a1869 "Today"', |
|
| 11 | + | "How are you today?", |
|
| 12 | + | '======= oxy conflict 1 of 2, side 3 of 3: change 55555555 "Third"', |
|
| 13 | + | "third", |
|
| 14 | + | "more third", |
|
| 15 | + | ">>>>>>> oxy conflict 1 of 2 end", |
|
| 16 | + | "z", |
|
| 17 | + | ]; |
|
| 18 | + | const regions = parseConflicts(lines); |
|
| 19 | + | assert.equal(regions.length, 1); |
|
| 20 | + | const r = regions[0]; |
|
| 21 | + | assert.deepEqual([r.number, r.total, r.start, r.end], [1, 2, 1, 8]); |
|
| 22 | + | assert.deepEqual(r.sides.map((s) => [s.number, s.label, s.start, s.end]), [ |
|
| 23 | + | [1, '872724ae "Doing"', 2, 3], |
|
| 24 | + | [2, '194a1869 "Today"', 4, 5], |
|
| 25 | + | [3, '55555555 "Third"', 6, 8], |
|
| 26 | + | ]); |
|
| 27 | + | assert.deepEqual(resolveRegion(lines, r, [2]), ["How are you today?"]); |
|
| 28 | + | assert.deepEqual(resolveRegion(lines, r, [1, 3]), ["How are you doing?", "third", "more third"]); |
|
| 29 | + | }); |
|
| 30 | + | ||
| 31 | + | test("parses delete versus insert conflicts", () => { |
|
| 32 | + | const lines = [ |
|
| 33 | + | "<<<<<<< oxy conflict 1 of 1, side 1 of 2: deleted by change 0101", |
|
| 34 | + | "-------", |
|
| 35 | + | "a", |
|
| 36 | + | "======= oxy conflict 1 of 1, side 2 of 2: change 0202", |
|
| 37 | + | "after-a", |
|
| 38 | + | ">>>>>>> oxy conflict 1 of 1 end", |
|
| 39 | + | ]; |
|
| 40 | + | const [r] = parseConflicts(lines); |
|
| 41 | + | assert.equal(r.sides[0].deleted, true); |
|
| 42 | + | assert.deepEqual([r.sides[0].start, r.sides[0].end], [2, 3]); |
|
| 43 | + | assert.deepEqual(resolveRegion(lines, r, [1]), [], "accepting the deletion keeps nothing"); |
|
| 44 | + | assert.deepEqual(resolveRegion(lines, r, [2]), ["after-a"]); |
|
| 45 | + | assert.deepEqual(parseConflicts(["plain", "text"]), []); |
|
| 46 | + | }); |
|
| 47 | + | ||
| 48 | + | test("candidate names", () => { |
|
| 49 | + | assert.equal(candidateBase("dir/a.txt.oxy-conflict-2"), "dir/a.txt"); |
|
| 50 | + | assert.equal(candidateBase("a.txt"), undefined); |
|
| 51 | + | }); |
modified CHANGELOG.md +4 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.5.0 (2026-09-27) |
|
| 4 | + | ||
| 5 | + | Milestone 0.5: conflict editor CodeLens over oxy markers (accept a side, all sides, or a deletion), keep-this-candidate for tree conflicts, Mark Resolved, and the commit action reads "Commit resolution" once every conflict is resolved. |
|
| 6 | + | ||
| 3 | 7 | ## 0.4.0 (2026-09-27) |
|
| 4 | 8 | ||
| 5 | 9 | 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. |
|
| … | 24 unchanged lines |
modified package.json +18 −5
| … | 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.4.0", |
|
| 5 | + | "version": "0.5.0", |
|
| 6 | 6 | "publisher": "oxygen", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygen.png", |
|
| … | 44 unchanged lines |
||
| 53 | 53 | { "command": "oxygen.showHistory", "title": "Show History", "category": "Oxygen", "icon": "$(git-commit)" }, |
|
| 54 | 54 | { "command": "oxygen.fileHistory", "title": "File History…", "category": "Oxygen", "icon": "$(history)" }, |
|
| 55 | 55 | { "command": "oxygen.toggleBlame", "title": "Toggle Inline Blame", "category": "Oxygen" }, |
|
| 56 | - | { "command": "oxygen.showChange", "title": "Show Change", "category": "Oxygen" } |
|
| 56 | + | { "command": "oxygen.showChange", "title": "Show Change", "category": "Oxygen" }, |
|
| 57 | + | { "command": "oxygen.acceptSides", "title": "Accept Conflict Sides", "category": "Oxygen" }, |
|
| 58 | + | { "command": "oxygen.keepCandidate", "title": "Keep This Candidate", "category": "Oxygen", "icon": "$(check)" }, |
|
| 59 | + | { "command": "oxygen.markResolved", "title": "Mark Resolved", "category": "Oxygen", "icon": "$(check-all)" }, |
|
| 60 | + | { "command": "oxygen.openConflict", "title": "Open Conflict", "category": "Oxygen" } |
|
| 57 | 61 | ], |
|
| 58 | 62 | "views": { |
|
| 59 | 63 | "scm": [ |
|
| … | 23 unchanged lines |
||
| 83 | 87 | { "command": "oxygen.commitHunks", "group": "2_commit@1", "when": "scmProvider == oxygen" }, |
|
| 84 | 88 | { "command": "oxygen.amend", "group": "2_commit@2", "when": "scmProvider == oxygen" }, |
|
| 85 | 89 | { "command": "oxygen.undo", "group": "3_undo", "when": "scmProvider == oxygen" }, |
|
| 90 | + | { "command": "oxygen.markResolved", "group": "2_commit@3", "when": "scmProvider == oxygen" }, |
|
| 86 | 91 | { "command": "oxygen.showHistory", "group": "1_view@1", "when": "scmProvider == oxygen" }, |
|
| 87 | 92 | { "command": "oxygen.showLog", "group": "1_view@2", "when": "scmProvider == oxygen" }, |
|
| 88 | 93 | { "command": "oxygen.showOutput", "group": "1_view@3", "when": "scmProvider == oxygen" } |
|
| … | 14 unchanged lines |
||
| 103 | 108 | { "command": "oxygen.openChanges", "group": "navigation", "when": "scmProvider == oxygen" }, |
|
| 104 | 109 | { "command": "oxygen.openBaseVersion", "group": "navigation", "when": "scmProvider == oxygen" }, |
|
| 105 | 110 | { "command": "oxygen.commitSelected", "group": "1_modification@1", "when": "scmProvider == oxygen" }, |
|
| 106 | - | { "command": "oxygen.discard", "group": "1_modification@2", "when": "scmProvider == oxygen" } |
|
| 111 | + | { "command": "oxygen.discard", "group": "1_modification@2", "when": "scmProvider == oxygen" }, |
|
| 112 | + | { "command": "oxygen.keepCandidate", "group": "inline@0", "when": "scmProvider == oxygen && scmResourceState == candidate" }, |
|
| 113 | + | { "command": "oxygen.keepCandidate", "group": "0_conflict", "when": "scmProvider == oxygen && scmResourceState == candidate" } |
|
| 114 | + | ], |
|
| 115 | + | "scm/resourceGroup/context": [ |
|
| 116 | + | { "command": "oxygen.markResolved", "group": "inline", "when": "scmProvider == oxygen && scmResourceGroup == conflicts" } |
|
| 107 | 117 | ], |
|
| 108 | 118 | "view/title": [ |
|
| 109 | 119 | { "command": "oxygen.refreshOperations", "group": "navigation", "when": "view == oxygen.operations" } |
|
| … | 9 unchanged lines |
||
| 119 | 129 | { "command": "oxygen.commitSelected", "when": "false" }, |
|
| 120 | 130 | { "command": "oxygen.undoTo", "when": "false" }, |
|
| 121 | 131 | { "command": "oxygen.refreshOperations", "when": "false" }, |
|
| 122 | - | { "command": "oxygen.showChange", "when": "false" } |
|
| 132 | + | { "command": "oxygen.showChange", "when": "false" }, |
|
| 133 | + | { "command": "oxygen.acceptSides", "when": "false" }, |
|
| 134 | + | { "command": "oxygen.keepCandidate", "when": "false" }, |
|
| 135 | + | { "command": "oxygen.openConflict", "when": "false" } |
|
| 123 | 136 | ] |
|
| 124 | 137 | }, |
|
| 125 | 138 | "configuration": { |
|
| … | 41 unchanged lines |
||
| 167 | 180 | "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap", |
|
| 168 | 181 | "watch": "npm run build -- --watch", |
|
| 169 | 182 | "typecheck": "tsc --noEmit -p .", |
|
| 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", |
|
| 183 | + | "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", |
|
| 171 | 184 | "test": "node --test \"test/**/*.test.mjs\"", |
|
| 172 | 185 | "check": "npm run typecheck && npm run build && npm test", |
|
| 173 | 186 | "package": "vsce package --no-dependencies" |
|
| … | 9 unchanged lines |
modified src/extension.ts +17 −1
| … | 9 unchanged lines |
||
| 10 | 10 | import { OperationItem, OperationsProvider, timeAgo } from "./operations"; |
|
| 11 | 11 | import { BlameController } from "./blame"; |
|
| 12 | 12 | import { HistoryPanel } from "./history"; |
|
| 13 | + | import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts"; |
|
| 13 | 14 | ||
| 14 | 15 | const MIN_VERSION = { format: 2, protocol: 1 }; |
|
| 15 | 16 | ||
| … | 3 unchanged lines |
||
| 19 | 20 | let decorations: OxyDecorationProvider; |
|
| 20 | 21 | let operations: OperationsProvider; |
|
| 21 | 22 | let blame: BlameController; |
|
| 23 | + | let conflictLenses: ConflictCodeLensProvider; |
|
| 22 | 24 | const repositories = new Map<string, Repository>(); |
|
| 23 | 25 | ||
| 24 | 26 | export async function activate(context: vscode.ExtensionContext): Promise<void> { |
|
| … | 24 unchanged lines |
||
| 49 | 51 | context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations)); |
|
| 50 | 52 | blame = new BlameController(() => [...repositories.values()]); |
|
| 51 | 53 | context.subscriptions.push(blame); |
|
| 54 | + | conflictLenses = new ConflictCodeLensProvider(() => [...repositories.values()]); |
|
| 55 | + | context.subscriptions.push(vscode.languages.registerCodeLensProvider({ scheme: "file" }, conflictLenses)); |
|
| 52 | 56 | ||
| 53 | 57 | context.subscriptions.push( |
|
| 54 | 58 | vscode.commands.registerCommand("oxygen.refresh", async () => { |
|
| … | 31 unchanged lines |
||
| 86 | 90 | vscode.commands.registerCommand("oxygen.fileHistory", (uri?: vscode.Uri) => fileHistory(uri ?? vscode.window.activeTextEditor?.document.uri)), |
|
| 87 | 91 | vscode.commands.registerCommand("oxygen.toggleBlame", () => blame.toggle()), |
|
| 88 | 92 | vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)), |
|
| 93 | + | vscode.commands.registerCommand("oxygen.acceptSides", (uri: vscode.Uri, index: number, keep: number[]) => acceptSides(uri, index, keep).catch(showError)), |
|
| 94 | + | vscode.commands.registerCommand("oxygen.keepCandidate", (state?: OxyResourceState) => state && keepCandidate(state.repository, state.path).catch(showError)), |
|
| 95 | + | vscode.commands.registerCommand("oxygen.markResolved", (arg?: Repository | vscode.SourceControl) => { |
|
| 96 | + | const repo = repositoryFor(arg); |
|
| 97 | + | return repo && markResolved(repo).catch(showError); |
|
| 98 | + | }), |
|
| 99 | + | vscode.commands.registerCommand("oxygen.openConflict", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)), |
|
| 89 | 100 | vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()), |
|
| 90 | 101 | vscode.workspace.onDidChangeConfiguration((e) => { |
|
| 91 | 102 | if (e.affectsConfiguration("oxygen.path")) { |
|
| … | 40 unchanged lines |
||
| 132 | 143 | for (const [root, folder] of wanted) { |
|
| 133 | 144 | if (!repositories.has(root)) { |
|
| 134 | 145 | const repo = new Repository(oxy, root, folder); |
|
| 135 | - | repo.onDidChangeStatus(() => { |
|
| 146 | + | repo.onDidChangeStatus((status) => { |
|
| 136 | 147 | decorations.refresh(); |
|
| 137 | 148 | contentProvider.invalidate(root); |
|
| 138 | 149 | operations.refresh(); |
|
| 139 | 150 | blame.invalidate(root); |
|
| 140 | 151 | HistoryPanel.refresh(root); |
|
| 152 | + | conflictLenses.refresh(); |
|
| 153 | + | // The commit action reads as a resolution while conflicts are resolved but uncommitted. |
|
| 154 | + | const resolved = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true); |
|
| 155 | + | repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: resolved ? "Commit resolution" : "Commit", arguments: [repo] }; |
|
| 156 | + | repo.sourceControl.inputBox.placeholder = resolved ? "Describe the resolution (Ctrl+Enter to commit)" : "Message (Ctrl+Enter to commit)"; |
|
| 141 | 157 | }); |
|
| 142 | 158 | repositories.set(root, repo); |
|
| 143 | 159 | output.appendLine(`repository ${root}`); |
|
| … | 640 unchanged lines |
modified src/repository.ts +3 −0
| … | 53 unchanged lines |
||
| 54 | 54 | faded: false, |
|
| 55 | 55 | }; |
|
| 56 | 56 | this.command = { command: "oxygen.openChanges", title: "Open Changes", arguments: [this] }; |
|
| 57 | + | this.contextValue = /\.oxy-conflict-\d+$/.test(path) ? "candidate" : status === "ConflictEdited" ? "markers" : status; |
|
| 57 | 58 | } |
|
| 58 | 59 | ||
| 60 | + | contextValue: string; |
|
| 61 | + | ||
| 59 | 62 | get letter(): string { |
|
| 60 | 63 | return STATUS_LETTER[this.status]; |
|
| 61 | 64 | } |
|
| … | 194 unchanged lines |