OXYGENHUB

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