OXYGENHUB

Milestone 0.2: discard, commit selected paths, commit chosen hunks, amend, undo, Oxygen Operations view with undo to here 35d56b5803d9

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 15:46 UTC
Signature
✔ signed by Andy McCall
Hash
35d56b5803d995d9d1b08a5cdc832dd759c921a0bfc04fb2ba2bb60fefa33416
Depends on
810a1c538e78 bed495a96408
Depended on by
dac6876e4d08 b55e0d00545e 44bfd5adfc9a b4626290e660 f6bda77ecb9f d2c9a5843004 25103dddd993 ab81294a9ba9 c8f955891224 5779e5c731a6
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
20 atoms, 1 tree operation, 6 files

Browse the tree at this change

added src/operations.ts +92 −0
1 +
// The operation log as a tree view (spec §5.9): newest first, with
2 +
// "Undo to here" on any entry, snapshots hidden unless configured.
3 +
4 +
import * as vscode from "vscode";
5 +
import { OperationInfo } from "./oxy";
6 +
import { Repository } from "./repository";
7 +
8 +
export class OperationItem extends vscode.TreeItem {
9 +
  constructor(
10 +
    readonly repository: Repository,
11 +
    readonly op: OperationInfo,
12 +
    readonly stepsBack: number,
13 +
  ) {
14 +
    super(op.command, vscode.TreeItemCollapsibleState.None);
15 +
    this.id = `${repository.root}:${op.id}`;
16 +
    this.description = timeAgo(op.when_ms);
17 +
    this.tooltip = `${op.command}\n${new Date(op.when_ms).toLocaleString()}\n${op.id.slice(0, 12)}`;
18 +
    this.iconPath = new vscode.ThemeIcon(op.current ? "circle-filled" : op.snapshot ? "circle-small" : "circle-outline");
19 +
    this.contextValue = op.current ? "current" : "operation";
20 +
  }
21 +
}
22 +
23 +
export class OperationsProvider implements vscode.TreeDataProvider<OperationItem | RepositoryItem> {
24 +
  private readonly emitter = new vscode.EventEmitter<void>();
25 +
  readonly onDidChangeTreeData = this.emitter.event;
26 +
27 +
  constructor(private readonly repositories: () => Repository[]) {}
28 +
29 +
  refresh(): void {
30 +
    this.emitter.fire();
31 +
  }
32 +
33 +
  getTreeItem(element: OperationItem | RepositoryItem): vscode.TreeItem {
34 +
    return element;
35 +
  }
36 +
37 +
  async getChildren(element?: OperationItem | RepositoryItem): Promise<(OperationItem | RepositoryItem)[]> {
38 +
    const repos = this.repositories();
39 +
    if (!element) {
40 +
      if (repos.length === 1) {
41 +
        return this.operations(repos[0]);
42 +
      }
43 +
      return repos.map((r) => new RepositoryItem(r));
44 +
    }
45 +
    if (element instanceof RepositoryItem) {
46 +
      return this.operations(element.repository);
47 +
    }
48 +
    return [];
49 +
  }
50 +
51 +
  private async operations(repo: Repository): Promise<OperationItem[]> {
52 +
    const showSnapshots = vscode.workspace.getConfiguration("oxygen").get<boolean>("showSnapshotOperations", false);
53 +
    try {
54 +
      const ops = await repo.oxy.opLog(repo.root);
55 +
      const items: OperationItem[] = [];
56 +
      // Steps back count every operation, snapshots included, since undo
57 +
      // skips snapshots itself only when they are the newest entries.
58 +
      let steps = 0;
59 +
      for (const op of ops) {
60 +
        if (showSnapshots || !op.snapshot || op.current) {
61 +
          items.push(new OperationItem(repo, op, steps));
62 +
        }
63 +
        if (!op.snapshot) {
64 +
          steps += 1;
65 +
        }
66 +
      }
67 +
      return items.slice(0, 200);
68 +
    } catch {
69 +
      return [];
70 +
    }
71 +
  }
72 +
}
73 +
74 +
export class RepositoryItem extends vscode.TreeItem {
75 +
  constructor(readonly repository: Repository) {
76 +
    super(repository.root.split("/").pop() ?? repository.root, vscode.TreeItemCollapsibleState.Expanded);
77 +
    this.iconPath = new vscode.ThemeIcon("repo");
78 +
    this.contextValue = "repository";
79 +
  }
80 +
}
81 +
82 +
export function timeAgo(ms: number): string {
83 +
  const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
84 +
  if (s < 45) return "just now";
85 +
  if (s < 90) return "a minute ago";
86 +
  if (s < 2700) return `${Math.round(s / 60)} minutes ago`;
87 +
  if (s < 5400) return "an hour ago";
88 +
  if (s < 79200) return `${Math.round(s / 3600)} hours ago`;
89 +
  if (s < 129600) return "yesterday";
90 +
  if (s < 2592000) return `${Math.round(s / 86400)} days ago`;
91 +
  return new Date(ms).toLocaleDateString();
92 +
}
modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## 0.2.0 (2026-09-27)
4 +
5 +
Milestone 0.2: discard changes (with confirmation), commit selected paths, commit chosen hunks, amend, undo the last operation, and an Oxygen Operations view with "Undo to here".
6 +
3 7
## 0.1.2 (2026-09-27)
4 8
5 9
Log from the start of activation, report when no repository is found, an Initialize Repository command, and a Source Control welcome entry.
…
12 unchanged lines
modified package.json +38 −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.1.2",
5 +
  "version": "0.2.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
24 unchanged lines
33 33
      { "command": "oxygen.openBaseVersion", "title": "Open Base Version", "category": "Oxygen" },
34 34
      { "command": "oxygen.showOutput", "title": "Show Output", "category": "Oxygen" },
35 35
      { "command": "oxygen.showLog", "title": "Show Log", "category": "Oxygen" },
36 -
      { "command": "oxygen.init", "title": "Initialize Repository", "category": "Oxygen" }
36 +
      { "command": "oxygen.init", "title": "Initialize Repository", "category": "Oxygen" },
37 +
      { "command": "oxygen.discard", "title": "Discard Changes", "category": "Oxygen", "icon": "$(discard)" },
38 +
      { "command": "oxygen.commitSelected", "title": "Commit Selected Paths", "category": "Oxygen" },
39 +
      { "command": "oxygen.commitHunks", "title": "Commit Hunks…", "category": "Oxygen" },
40 +
      { "command": "oxygen.amend", "title": "Amend", "category": "Oxygen" },
41 +
      { "command": "oxygen.undo", "title": "Undo Last Operation", "category": "Oxygen", "icon": "$(discard)" },
42 +
      { "command": "oxygen.undoTo", "title": "Undo to Here", "category": "Oxygen", "icon": "$(discard)" },
43 +
      { "command": "oxygen.refreshOperations", "title": "Refresh Operations", "category": "Oxygen", "icon": "$(refresh)" }
37 44
    ],
45 +
    "views": {
46 +
      "scm": [
47 +
        { "id": "oxygen.operations", "name": "Oxygen Operations", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" }
48 +
      ]
49 +
    },
38 50
    "viewsWelcome": [
39 51
      {
40 52
        "view": "scm",
…
5 unchanged lines
46 58
      "scm/title": [
47 59
        { "command": "oxygen.commit", "group": "navigation@1", "when": "scmProvider == oxygen" },
48 60
        { "command": "oxygen.refresh", "group": "navigation@2", "when": "scmProvider == oxygen" },
61 +
        { "command": "oxygen.commitHunks", "group": "2_commit@1", "when": "scmProvider == oxygen" },
62 +
        { "command": "oxygen.amend", "group": "2_commit@2", "when": "scmProvider == oxygen" },
63 +
        { "command": "oxygen.undo", "group": "3_undo", "when": "scmProvider == oxygen" },
49 64
        { "command": "oxygen.showLog", "group": "1_view", "when": "scmProvider == oxygen" },
50 65
        { "command": "oxygen.showOutput", "group": "1_view", "when": "scmProvider == oxygen" }
51 66
      ],
52 67
      "scm/resourceState/context": [
53 -
        { "command": "oxygen.openFile", "group": "inline", "when": "scmProvider == oxygen" },
68 +
        { "command": "oxygen.openFile", "group": "inline@1", "when": "scmProvider == oxygen" },
69 +
        { "command": "oxygen.discard", "group": "inline@2", "when": "scmProvider == oxygen" },
54 70
        { "command": "oxygen.openChanges", "group": "navigation", "when": "scmProvider == oxygen" },
55 -
        { "command": "oxygen.openBaseVersion", "group": "navigation", "when": "scmProvider == oxygen" }
71 +
        { "command": "oxygen.openBaseVersion", "group": "navigation", "when": "scmProvider == oxygen" },
72 +
        { "command": "oxygen.commitSelected", "group": "1_modification@1", "when": "scmProvider == oxygen" },
73 +
        { "command": "oxygen.discard", "group": "1_modification@2", "when": "scmProvider == oxygen" }
56 74
      ],
75 +
      "view/title": [
76 +
        { "command": "oxygen.refreshOperations", "group": "navigation", "when": "view == oxygen.operations" }
77 +
      ],
78 +
      "view/item/context": [
79 +
        { "command": "oxygen.undoTo", "group": "inline", "when": "view == oxygen.operations && viewItem == operation" }
80 +
      ],
57 81
      "commandPalette": [
58 82
        { "command": "oxygen.openFile", "when": "false" },
59 83
        { "command": "oxygen.openChanges", "when": "false" },
60 -
        { "command": "oxygen.openBaseVersion", "when": "false" }
84 +
        { "command": "oxygen.openBaseVersion", "when": "false" },
85 +
        { "command": "oxygen.discard", "when": "false" },
86 +
        { "command": "oxygen.commitSelected", "when": "false" },
87 +
        { "command": "oxygen.undoTo", "when": "false" },
88 +
        { "command": "oxygen.refreshOperations", "when": "false" }
61 89
      ]
62 90
    },
63 91
    "configuration": {
…
14 unchanged lines
78 106
          "default": true,
79 107
          "description": "Show status decorations in the explorer and on editor tabs."
80 108
        },
109 +
        "oxygen.showSnapshotOperations": {
110 +
          "type": "boolean",
111 +
          "default": false,
112 +
          "description": "List snapshot operations in the Oxygen Operations view."
113 +
        },
81 114
        "oxygen.statusBar.enabled": {
82 115
          "type": "boolean",
83 116
          "default": true,
…
22 unchanged lines
modified src/extension.ts +137 −0
…
6 unchanged lines
7 7
import { Repository, OxyResourceState } from "./repository";
8 8
import { OxyContentProvider, SCHEME, baseUri } from "./contentProvider";
9 9
import { OxyDecorationProvider } from "./decorations";
10 +
import { OperationItem, OperationsProvider } from "./operations";
10 11
11 12
const MIN_VERSION = { format: 2, protocol: 1 };
12 13
…
1 unchanged lines
14 15
let oxy: Oxy;
15 16
let contentProvider: OxyContentProvider;
16 17
let decorations: OxyDecorationProvider;
18 +
let operations: OperationsProvider;
17 19
const repositories = new Map<string, Repository>();
18 20
19 21
export async function activate(context: vscode.ExtensionContext): Promise<void> {
…
20 unchanged lines
40 42
  context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider));
41 43
  decorations = new OxyDecorationProvider(() => [...repositories.values()]);
42 44
  context.subscriptions.push(decorations);
45 +
  operations = new OperationsProvider(() => [...repositories.values()]);
46 +
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
43 47
44 48
  context.subscriptions.push(
45 49
    vscode.commands.registerCommand("oxygen.refresh", async () => {
…
6 unchanged lines
52 56
    vscode.commands.registerCommand("oxygen.commit", (arg?: Repository | vscode.SourceControl) => commit(arg)),
53 57
    vscode.commands.registerCommand("oxygen.showLog", () => showLog()),
54 58
    vscode.commands.registerCommand("oxygen.init", () => initRepository()),
59 +
    vscode.commands.registerCommand("oxygen.discard", (state?: OxyResourceState, states?: OxyResourceState[]) => discard(states?.length ? states : state ? [state] : [])),
60 +
    vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
61 +
    vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
62 +
    vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
63 +
    vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
64 +
    vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
65 +
    vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
55 66
    vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
56 67
    vscode.workspace.onDidChangeConfiguration((e) => {
57 68
      if (e.affectsConfiguration("oxygen.path")) {
…
43 unchanged lines
101 112
      repo.onDidChangeStatus(() => {
102 113
        decorations.refresh();
103 114
        contentProvider.invalidate(root);
115 +
        operations.refresh();
104 116
      });
105 117
      repositories.set(root, repo);
106 118
      output.appendLine(`repository ${root}`);
…
74 unchanged lines
181 193
  }
182 194
}
183 195
196 +
async function discard(states: OxyResourceState[]): Promise<void> {
197 +
  if (states.length === 0) {
198 +
    return;
199 +
  }
200 +
  const repo = states[0].repository;
201 +
  const names = states.length === 1 ? basename(states[0].path) : `${states.length} files`;
202 +
  const pick = await vscode.window.showWarningMessage(`Discard changes in ${names}? This cannot be undone with oxy undo.`, { modal: true }, "Discard");
203 +
  if (pick !== "Discard") {
204 +
    return;
205 +
  }
206 +
  try {
207 +
    await repo.oxy.discard(repo.root, states.map((s) => s.path));
208 +
    await repo.refresh();
209 +
  } catch (e) {
210 +
    showError(e);
211 +
  }
212 +
}
213 +
214 +
async function commitSelected(states: OxyResourceState[]): Promise<void> {
215 +
  if (states.length === 0) {
216 +
    return;
217 +
  }
218 +
  const repo = states[0].repository;
219 +
  const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`);
220 +
  if (!message) {
221 +
    return;
222 +
  }
223 +
  try {
224 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path)));
225 +
    repo.sourceControl.inputBox.value = "";
226 +
    await repo.refresh();
227 +
  } catch (e) {
228 +
    showError(e);
229 +
  }
230 +
}
231 +
232 +
async function commitHunks(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
233 +
  const repo = repositoryFor(arg);
234 +
  if (!repo) {
235 +
    return;
236 +
  }
237 +
  let hunks;
238 +
  try {
239 +
    hunks = await repo.oxy.hunks(repo.root);
240 +
  } catch (e) {
241 +
    showError(e);
242 +
    return;
243 +
  }
244 +
  if (hunks.length === 0) {
245 +
    void vscode.window.showInformationMessage("Oxygen: nothing to commit.");
246 +
    return;
247 +
  }
248 +
  const picks = await vscode.window.showQuickPick(
249 +
    hunks.map((h) => ({
250 +
      label: `$(diff) ${h.path}`,
251 +
      description: `#${h.index}`,
252 +
      detail: h.text.split("\n").filter((l) => l.startsWith("+") || l.startsWith("-") || l.startsWith("A ") || l.startsWith("D ") || l.startsWith("R ")).slice(0, 3).join("  ").slice(0, 160),
253 +
      hunk: h,
254 +
      picked: false,
255 +
    })),
256 +
    { canPickMany: true, placeHolder: "Choose the hunks to commit; anything a chosen hunk needs comes along" },
257 +
  );
258 +
  if (!picks || picks.length === 0) {
259 +
    return;
260 +
  }
261 +
  const message = await askMessage(repo, `Commit ${picks.length} hunk${picks.length === 1 ? "" : "s"}`);
262 +
  if (!message) {
263 +
    return;
264 +
  }
265 +
  try {
266 +
    await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index));
267 +
    repo.sourceControl.inputBox.value = "";
268 +
    await repo.refresh();
269 +
  } catch (e) {
270 +
    showError(e);
271 +
  }
272 +
}
273 +
274 +
async function amend(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
275 +
  const repo = repositoryFor(arg);
276 +
  if (!repo) {
277 +
    return;
278 +
  }
279 +
  const message = repo.sourceControl.inputBox.value.trim();
280 +
  try {
281 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined));
282 +
    repo.sourceControl.inputBox.value = "";
283 +
    await repo.refresh();
284 +
  } catch (e) {
285 +
    showError(e);
286 +
  }
287 +
}
288 +
289 +
async function undo(arg: Repository | vscode.SourceControl | undefined, steps: number): Promise<void> {
290 +
  const repo = repositoryFor(arg);
291 +
  if (!repo || steps <= 0) {
292 +
    return;
293 +
  }
294 +
  if (steps > 1) {
295 +
    const pick = await vscode.window.showWarningMessage(`Undo the last ${steps} operations?`, { modal: true }, "Undo");
296 +
    if (pick !== "Undo") {
297 +
      return;
298 +
    }
299 +
  }
300 +
  try {
301 +
    let last = "";
302 +
    for (let i = 0; i < steps; i++) {
303 +
      last = await repo.oxy.undo(repo.root);
304 +
    }
305 +
    await repo.refresh();
306 +
    vscode.window.setStatusBarMessage(`$(discard) ${last || "Undone"}`, 4000);
307 +
  } catch (e) {
308 +
    showError(e);
309 +
  }
310 +
}
311 +
312 +
/** The commit message from the input box, else a prompt. */
313 +
async function askMessage(repo: Repository, prompt: string): Promise<string | undefined> {
314 +
  const fromBox = repo.sourceControl.inputBox.value.trim();
315 +
  if (fromBox) {
316 +
    return fromBox;
317 +
  }
318 +
  return (await vscode.window.showInputBox({ prompt, placeHolder: "Commit message" }))?.trim() || undefined;
319 +
}
320 +
184 321
async function showLog(): Promise<void> {
185 322
  const repo = repositoryFor(undefined);
186 323
  if (!repo) {
…
26 unchanged lines
modified src/oxy.ts +47 −0
…
138 unchanged lines
139 139
  bookmarks(root: string): Promise<BookmarkInfo[]> {
140 140
    return this.json<BookmarkInfo[]>(root, ["bookmark", "list"]);
141 141
  }
142 +
143 +
  /** Restore paths to the working copy's base. */
144 +
  discard(root: string, paths: string[]): Promise<void> {
145 +
    return this.check(root, ["discard", ...paths]).then(() => undefined);
146 +
  }
147 +
148 +
  discardHunks(root: string, hunks: number[]): Promise<void> {
149 +
    return this.check(root, ["discard", "--hunks", hunks.join(",")]).then(() => undefined);
150 +
  }
151 +
152 +
  hunks(root: string): Promise<HunkInfo[]> {
153 +
    return this.json<HunkInfo[]>(root, ["hunks"]);
154 +
  }
155 +
156 +
  commitHunks(root: string, message: string, hunks: number[]): Promise<void> {
157 +
    return this.check(root, ["commit", "-m", message, "--hunks", hunks.join(",")]).then(() => undefined);
158 +
  }
159 +
160 +
  amend(root: string, message?: string): Promise<void> {
161 +
    const args = ["amend"];
162 +
    if (message) {
163 +
      args.push("-m", message);
164 +
    }
165 +
    return this.check(root, args).then(() => undefined);
166 +
  }
167 +
168 +
  undo(root: string): Promise<string> {
169 +
    return this.check(root, ["undo"]).then((r) => r.stdout.toString("utf8").trim());
170 +
  }
171 +
172 +
  opLog(root: string): Promise<OperationInfo[]> {
173 +
    return this.json<OperationInfo[]>(root, ["op", "log"]);
174 +
  }
175 +
}
176 +
177 +
export interface HunkInfo {
178 +
  index: number;
179 +
  path: string;
180 +
  text: string;
181 +
}
182 +
183 +
export interface OperationInfo {
184 +
  id: string;
185 +
  when_ms: number;
186 +
  command: string;
187 +
  snapshot: boolean;
188 +
  current: boolean;
142 189
}
143 190
144 191
// ---------------------------------------------------------------- types
…
61 unchanged lines
modified test/oxy.test.mjs +20 −0
…
43 unchanged lines
44 44
  const bookmarks = await oxy.bookmarks(root);
45 45
  assert.deepEqual(bookmarks.map((b) => [b.name, b.current]), [["main", true]]);
46 46
  await assert.rejects(oxy.cat(root, "missing.txt"), /not in that state/);
47 +
48 +
  // Write operations: hunks, commit by hunk, discard, amend, undo, op log.
49 +
  writeFileSync(join(root, "b.txt"), "b\n");
50 +
  const hunks = await oxy.hunks(root);
51 +
  assert.ok(hunks.length >= 2, JSON.stringify(hunks));
52 +
  const bHunk = hunks.find((h) => h.path === "b.txt");
53 +
  await oxy.commitHunks(root, "b only", [bHunk.index]);
54 +
  status = await oxy.status(root);
55 +
  assert.deepEqual(status.paths.map((p) => p.path), ["a.txt"], "a.txt stays uncommitted");
56 +
  await oxy.discard(root, ["a.txt"]);
57 +
  assert.equal((await oxy.status(root)).clean, true);
58 +
  writeFileSync(join(root, "b.txt"), "b\nmore\n");
59 +
  await oxy.amend(root, "b, amended");
60 +
  assert.equal((await oxy.status(root)).clean, true);
61 +
  const log2 = await oxy.log(root, 10);
62 +
  assert.ok(log2.changes.some((c) => c.message === "b, amended"));
63 +
  const ops = await oxy.opLog(root);
64 +
  assert.ok(ops[0].current && ops.length > 3);
65 +
  assert.match(await oxy.undo(root), /Undone/);
66 +
  assert.equal((await oxy.status(root)).clean, false, "the amend is undone; its edit is back on disk as uncommitted work");
47 67
});