OXYGENHUB

Milestone 0.3: bookmark picker (switch, create, merge, delete), fetch, pull, push and push bookmark with results, sync counter and Sync, push confirmation c8f955891224

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 15:51 UTC
Signature
✔ signed by Andy McCall
Hash
c8f9558912241efcdc2ffce9f3de6c0ad8e592174b56e0ddb94323534c059006
Depends on
35d56b5803d9 5779e5c731a6 bed495a96408
Depended on by
dac6876e4d08 b55e0d00545e 0c26846a9a81 b4626290e660 f6bda77ecb9f d2c9a5843004 ab81294a9ba9
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
18 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## 0.3.0 (2026-09-27)
4 +
5 +
Milestone 0.3: bookmark picker in the status bar (switch, create, merge, delete), fetch, pull, push and push-bookmark with results, a sync counter and Sync action, confirmation before pushing.
6 +
3 7
## 0.2.0 (2026-09-27)
4 8
5 9
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".
…
16 unchanged lines
modified package.json +26 −3
…
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.2.1",
5 +
  "version": "0.3.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
31 unchanged lines
40 40
      { "command": "oxygen.amend", "title": "Amend", "category": "Oxygen" },
41 41
      { "command": "oxygen.undo", "title": "Undo Last Operation", "category": "Oxygen", "icon": "$(discard)" },
42 42
      { "command": "oxygen.undoTo", "title": "Undo to Here", "category": "Oxygen", "icon": "$(discard)" },
43 -
      { "command": "oxygen.refreshOperations", "title": "Refresh Operations", "category": "Oxygen", "icon": "$(refresh)" }
43 +
      { "command": "oxygen.refreshOperations", "title": "Refresh Operations", "category": "Oxygen", "icon": "$(refresh)" },
44 +
      { "command": "oxygen.switchBookmark", "title": "Switch Bookmark…", "category": "Oxygen", "icon": "$(bookmark)" },
45 +
      { "command": "oxygen.createBookmark", "title": "Create Bookmark…", "category": "Oxygen" },
46 +
      { "command": "oxygen.mergeBookmark", "title": "Merge Bookmark…", "category": "Oxygen" },
47 +
      { "command": "oxygen.deleteBookmark", "title": "Delete Bookmark…", "category": "Oxygen" },
48 +
      { "command": "oxygen.fetch", "title": "Fetch", "category": "Oxygen" },
49 +
      { "command": "oxygen.pull", "title": "Pull", "category": "Oxygen" },
50 +
      { "command": "oxygen.push", "title": "Push", "category": "Oxygen" },
51 +
      { "command": "oxygen.pushBookmark", "title": "Push Bookmark…", "category": "Oxygen" },
52 +
      { "command": "oxygen.sync", "title": "Sync", "category": "Oxygen", "icon": "$(sync)" }
44 53
    ],
45 54
    "views": {
46 55
      "scm": [
…
10 unchanged lines
57 66
    "menus": {
58 67
      "scm/title": [
59 68
        { "command": "oxygen.commit", "group": "navigation@1", "when": "scmProvider == oxygen" },
60 -
        { "command": "oxygen.refresh", "group": "navigation@2", "when": "scmProvider == oxygen" },
69 +
        { "command": "oxygen.sync", "group": "navigation@2", "when": "scmProvider == oxygen" },
70 +
        { "command": "oxygen.refresh", "group": "navigation@3", "when": "scmProvider == oxygen" },
71 +
        { "command": "oxygen.pull", "group": "4_sync@1", "when": "scmProvider == oxygen" },
72 +
        { "command": "oxygen.push", "group": "4_sync@2", "when": "scmProvider == oxygen" },
73 +
        { "command": "oxygen.pushBookmark", "group": "4_sync@3", "when": "scmProvider == oxygen" },
74 +
        { "command": "oxygen.fetch", "group": "4_sync@4", "when": "scmProvider == oxygen" },
75 +
        { "command": "oxygen.switchBookmark", "group": "5_bookmarks@1", "when": "scmProvider == oxygen" },
76 +
        { "command": "oxygen.createBookmark", "group": "5_bookmarks@2", "when": "scmProvider == oxygen" },
77 +
        { "command": "oxygen.mergeBookmark", "group": "5_bookmarks@3", "when": "scmProvider == oxygen" },
78 +
        { "command": "oxygen.deleteBookmark", "group": "5_bookmarks@4", "when": "scmProvider == oxygen" },
61 79
        { "command": "oxygen.commitHunks", "group": "2_commit@1", "when": "scmProvider == oxygen" },
62 80
        { "command": "oxygen.amend", "group": "2_commit@2", "when": "scmProvider == oxygen" },
63 81
        { "command": "oxygen.undo", "group": "3_undo", "when": "scmProvider == oxygen" },
…
42 unchanged lines
106 124
          "default": true,
107 125
          "description": "Show status decorations in the explorer and on editor tabs."
108 126
        },
127 +
        "oxygen.confirmSync": {
128 +
          "type": "boolean",
129 +
          "default": true,
130 +
          "description": "Ask before pushing."
131 +
        },
109 132
        "oxygen.showSnapshotOperations": {
110 133
          "type": "boolean",
111 134
          "default": false,
…
27 unchanged lines
modified src/extension.ts +280 −0
…
62 unchanged lines
63 63
    vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
64 64
    vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
65 65
    vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
66 +
    vscode.commands.registerCommand("oxygen.switchBookmark", (arg?: Repository | vscode.SourceControl) => switchBookmark(arg)),
67 +
    vscode.commands.registerCommand("oxygen.createBookmark", (arg?: Repository | vscode.SourceControl) => createBookmark(arg)),
68 +
    vscode.commands.registerCommand("oxygen.mergeBookmark", (arg?: Repository | vscode.SourceControl) => mergeBookmark(arg)),
69 +
    vscode.commands.registerCommand("oxygen.deleteBookmark", (arg?: Repository | vscode.SourceControl) => deleteBookmark(arg)),
70 +
    vscode.commands.registerCommand("oxygen.fetch", (arg?: Repository | vscode.SourceControl) => fetch(arg)),
71 +
    vscode.commands.registerCommand("oxygen.pull", (arg?: Repository | vscode.SourceControl) => pull(arg)),
72 +
    vscode.commands.registerCommand("oxygen.push", (arg?: Repository | vscode.SourceControl) => push(arg, false)),
73 +
    vscode.commands.registerCommand("oxygen.pushBookmark", (arg?: Repository | vscode.SourceControl) => push(arg, true)),
74 +
    vscode.commands.registerCommand("oxygen.sync", (arg?: Repository | vscode.SourceControl) => sync(arg)),
66 75
    vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
67 76
    vscode.workspace.onDidChangeConfiguration((e) => {
68 77
      if (e.affectsConfiguration("oxygen.path")) {
…
240 unchanged lines
309 318
  }
310 319
}
311 320
321 +
// ---------------------------------------------------------------- bookmarks
322 +
323 +
async function switchBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
324 +
  const repo = repositoryFor(arg);
325 +
  if (!repo) {
326 +
    return;
327 +
  }
328 +
  let bookmarks;
329 +
  try {
330 +
    bookmarks = await repo.oxy.bookmarks(repo.root);
331 +
  } catch (e) {
332 +
    showError(e);
333 +
    return;
334 +
  }
335 +
  type Item = vscode.QuickPickItem & { action: "switch" | "create" | "merge" | "delete"; name?: string };
336 +
  const items: Item[] = bookmarks.map((b) => ({
337 +
    label: `${b.current ? "$(circle-filled)" : "$(bookmark)"} ${b.name}`,
338 +
    description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}${b.ahead ? ` $(arrow-up)${b.ahead}` : ""}${b.behind ? ` $(arrow-down)${b.behind}` : ""}`,
339 +
    detail: b.current ? "current bookmark" : undefined,
340 +
    action: "switch",
341 +
    name: b.name,
342 +
  }));
343 +
  items.push(
344 +
    { label: "$(add) Create bookmark…", action: "create", alwaysShow: true },
345 +
    { label: "$(git-merge) Merge a bookmark into the current one…", action: "merge", alwaysShow: true },
346 +
    { label: "$(trash) Delete a bookmark…", action: "delete", alwaysShow: true },
347 +
  );
348 +
  const pick = await vscode.window.showQuickPick(items, { placeHolder: "Switch to a bookmark" });
349 +
  if (!pick) {
350 +
    return;
351 +
  }
352 +
  switch (pick.action) {
353 +
    case "create":
354 +
      return createBookmark(repo);
355 +
    case "merge":
356 +
      return mergeBookmark(repo);
357 +
    case "delete":
358 +
      return deleteBookmark(repo);
359 +
    case "switch":
360 +
      if (pick.name && !bookmarks.find((b) => b.name === pick.name)?.current) {
361 +
        await doSwitch(repo, pick.name);
362 +
      }
363 +
  }
364 +
}
365 +
366 +
async function doSwitch(repo: Repository, name: string): Promise<void> {
367 +
  try {
368 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${name}` }, () => repo.oxy.bookmarkSwitch(repo.root, name));
369 +
  } catch (e) {
370 +
    if (e instanceof OxyError && e.message.includes("has changes")) {
371 +
      const pick = await vscode.window.showWarningMessage(`The working copy has uncommitted changes. Switching to ${name} would discard them.`, { modal: true }, "Discard and switch");
372 +
      if (pick === "Discard and switch") {
373 +
        try {
374 +
          await repo.oxy.bookmarkSwitch(repo.root, name, true);
375 +
        } catch (e2) {
376 +
          showError(e2);
377 +
        }
378 +
      }
379 +
    } else {
380 +
      showError(e);
381 +
    }
382 +
  }
383 +
  await repo.refresh();
384 +
}
385 +
386 +
async function createBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
387 +
  const repo = repositoryFor(arg);
388 +
  if (!repo) {
389 +
    return;
390 +
  }
391 +
  const name = await vscode.window.showInputBox({
392 +
    prompt: "New bookmark name (created at the working copy's base)",
393 +
    validateInput: (v) => (!v.trim() || v.startsWith("-") || v.includes("..") || v.includes("//") || /[\s@{]/.test(v) ? "Not a valid bookmark name" : undefined),
394 +
  });
395 +
  if (!name) {
396 +
    return;
397 +
  }
398 +
  try {
399 +
    await repo.oxy.bookmarkCreate(repo.root, name.trim());
400 +
    const pick = await vscode.window.showInformationMessage(`Oxygen: created bookmark ${name}.`, "Switch to it");
401 +
    if (pick) {
402 +
      await doSwitch(repo, name.trim());
403 +
    }
404 +
    await repo.refresh();
405 +
  } catch (e) {
406 +
    showError(e);
407 +
  }
408 +
}
409 +
410 +
async function pickBookmark(repo: Repository, placeHolder: string, excludeCurrent: boolean): Promise<string | undefined> {
411 +
  const bookmarks = await repo.oxy.bookmarks(repo.root);
412 +
  const items = bookmarks.filter((b) => !excludeCurrent || !b.current).map((b) => ({ label: b.name, description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}` }));
413 +
  if (items.length === 0) {
414 +
    void vscode.window.showInformationMessage("Oxygen: no other bookmarks.");
415 +
    return undefined;
416 +
  }
417 +
  return (await vscode.window.showQuickPick(items, { placeHolder }))?.label;
418 +
}
419 +
420 +
async function mergeBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
421 +
  const repo = repositoryFor(arg);
422 +
  if (!repo) {
423 +
    return;
424 +
  }
425 +
  try {
426 +
    const name = await pickBookmark(repo, `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, true);
427 +
    if (!name) {
428 +
      return;
429 +
    }
430 +
    const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Merging ${name}` }, () => repo.oxy.merge(repo.root, name));
431 +
    await repo.refresh();
432 +
    if (repo.status?.conflicted) {
433 +
      void vscode.window.showWarningMessage(`Oxygen: merged ${name}; the result has conflicts to resolve.`);
434 +
    } else {
435 +
      vscode.window.setStatusBarMessage(`$(git-merge) ${out.split("\n")[0]}`, 5000);
436 +
    }
437 +
  } catch (e) {
438 +
    showError(e);
439 +
  }
440 +
}
441 +
442 +
async function deleteBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
443 +
  const repo = repositoryFor(arg);
444 +
  if (!repo) {
445 +
    return;
446 +
  }
447 +
  try {
448 +
    const name = await pickBookmark(repo, "Delete which bookmark? Its changes stay.", true);
449 +
    if (!name) {
450 +
      return;
451 +
    }
452 +
    const pick = await vscode.window.showWarningMessage(`Delete bookmark ${name}?`, { modal: true }, "Delete");
453 +
    if (pick !== "Delete") {
454 +
      return;
455 +
    }
456 +
    await repo.oxy.bookmarkDelete(repo.root, name);
457 +
    await repo.refresh();
458 +
  } catch (e) {
459 +
    showError(e);
460 +
  }
461 +
}
462 +
463 +
// ---------------------------------------------------------------- sync
464 +
465 +
async function pickRemote(repo: Repository): Promise<string | undefined> {
466 +
  const remotes = await repo.oxy.remotes(repo.root);
467 +
  if (remotes.length === 0) {
468 +
    void vscode.window.showErrorMessage("Oxygen: this repository has no remotes. Add one with oxy remote add <name> <url>.");
469 +
    return undefined;
470 +
  }
471 +
  if (remotes.length === 1) {
472 +
    return remotes[0].name;
473 +
  }
474 +
  return (await vscode.window.showQuickPick(remotes.map((r) => ({ label: r.name, description: r.url })), { placeHolder: "Remote" }))?.label;
475 +
}
476 +
477 +
async function fetch(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
478 +
  const repo = repositoryFor(arg);
479 +
  if (!repo) {
480 +
    return;
481 +
  }
482 +
  try {
483 +
    const remote = await pickRemote(repo);
484 +
    if (!remote) {
485 +
      return;
486 +
    }
487 +
    const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Fetching from ${remote}` }, () => repo.oxy.fetch(repo.root, remote));
488 +
    await repo.refresh();
489 +
    const fresh = info.refs.filter((r) => r.new > 0);
490 +
    const summary = fresh.length ? fresh.map((r) => `${r.name}: ${r.new} new`).join(", ") : "nothing new";
491 +
    vscode.window.setStatusBarMessage(`$(cloud-download) ${remote}: ${summary}`, 5000);
492 +
    if (info.unverified) {
493 +
      void vscode.window.showWarningMessage(`Oxygen: ${remote} has no trust policy; nothing fetched from it was verified.`);
494 +
    }
495 +
  } catch (e) {
496 +
    showError(e);
497 +
  }
498 +
}
499 +
500 +
async function pull(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
501 +
  const repo = repositoryFor(arg);
502 +
  if (!repo) {
503 +
    return;
504 +
  }
505 +
  try {
506 +
    const remote = await pickRemote(repo);
507 +
    if (!remote) {
508 +
      return;
509 +
    }
510 +
    const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pulling from ${remote}` }, () => repo.oxy.pull(repo.root, remote));
511 +
    await repo.refresh();
512 +
    if (!info.changed) {
513 +
      vscode.window.setStatusBarMessage("$(check) Already up to date", 4000);
514 +
    } else if (info.conflicted) {
515 +
      void vscode.window.showWarningMessage("Oxygen: pulled; the result has conflicts to resolve.");
516 +
    } else if (!info.checked_out) {
517 +
      void vscode.window.showInformationMessage("Oxygen: pulled; the working copy was not updated because it has changes. Check out when ready.");
518 +
    } else {
519 +
      vscode.window.setStatusBarMessage(`$(cloud-download) Pulled from ${remote}`, 4000);
520 +
    }
521 +
  } catch (e) {
522 +
    showError(e);
523 +
  }
524 +
}
525 +
526 +
async function push(arg: Repository | vscode.SourceControl | undefined, chooseBookmark: boolean): Promise<void> {
527 +
  const repo = repositoryFor(arg);
528 +
  if (!repo) {
529 +
    return;
530 +
  }
531 +
  try {
532 +
    const remote = await pickRemote(repo);
533 +
    if (!remote) {
534 +
      return;
535 +
    }
536 +
    let bookmark: string | undefined;
537 +
    if (chooseBookmark) {
538 +
      bookmark = await pickBookmark(repo, "Push which bookmark?", false);
539 +
      if (!bookmark) {
540 +
        return;
541 +
      }
542 +
    }
543 +
    if (vscode.workspace.getConfiguration("oxygen").get<boolean>("confirmSync", true)) {
544 +
      const pick = await vscode.window.showInformationMessage(`Push ${bookmark ?? repo.currentBookmark?.name ?? "the current bookmark"} to ${remote}?`, { modal: false }, "Push", "Always push without asking");
545 +
      if (!pick) {
546 +
        return;
547 +
      }
548 +
      if (pick === "Always push without asking") {
549 +
        await vscode.workspace.getConfiguration("oxygen").update("confirmSync", false, vscode.ConfigurationTarget.Global);
550 +
      }
551 +
    }
552 +
    const results = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pushing to ${remote}` }, () => repo.oxy.push(repo.root, remote, bookmark));
553 +
    await repo.refresh();
554 +
    const rejected = results.filter((r) => !r.ok);
555 +
    if (rejected.length === 0) {
556 +
      vscode.window.setStatusBarMessage(`$(cloud-upload) Pushed to ${remote}`, 4000);
557 +
      return;
558 +
    }
559 +
    const reason = rejected.map((r) => `${r.name}: ${r.reason}`).join("; ");
560 +
    const pick = await vscode.window.showErrorMessage(`Oxygen: push rejected. ${reason}`, "Pull", "Show Output");
561 +
    if (pick === "Pull") {
562 +
      await pull(repo);
563 +
    } else if (pick === "Show Output") {
564 +
      output.show();
565 +
    }
566 +
  } catch (e) {
567 +
    if (e instanceof OxyError && e.message.includes("pull first")) {
568 +
      const pick = await vscode.window.showWarningMessage(`Oxygen: ${e.message}`, "Pull");
569 +
      if (pick === "Pull") {
570 +
        await pull(repo);
571 +
      }
572 +
      return;
573 +
    }
574 +
    showError(e);
575 +
  }
576 +
}
577 +
578 +
async function sync(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
579 +
  const repo = repositoryFor(arg);
580 +
  if (!repo) {
581 +
    return;
582 +
  }
583 +
  await pull(repo);
584 +
  if (repo.status?.conflicted) {
585 +
    return;
586 +
  }
587 +
  if ((repo.currentBookmark?.ahead ?? 0) > 0) {
588 +
    await push(repo, false);
589 +
  }
590 +
}
591 +
312 592
/** The commit message from the input box, else a prompt. */
313 593
async function askMessage(repo: Repository, prompt: string): Promise<string | undefined> {
314 594
  const fromBox = repo.sourceControl.inputBox.value.trim();
…
35 unchanged lines
modified src/oxy.ts +78 −0
…
171 unchanged lines
172 172
  opLog(root: string): Promise<OperationInfo[]> {
173 173
    return this.json<OperationInfo[]>(root, ["op", "log"]);
174 174
  }
175 +
176 +
  // ---------------------------------------------------------------- bookmarks
177 +
178 +
  bookmarkCreate(root: string, name: string, changes: string[] = []): Promise<void> {
179 +
    return this.check(root, ["bookmark", "create", name, ...changes]).then(() => undefined);
180 +
  }
181 +
182 +
  bookmarkSwitch(root: string, name: string, discard = false): Promise<void> {
183 +
    const args = ["bookmark", "switch", name];
184 +
    if (discard) {
185 +
      args.push("--discard");
186 +
    }
187 +
    return this.check(root, args).then(() => undefined);
188 +
  }
189 +
190 +
  bookmarkDelete(root: string, name: string): Promise<void> {
191 +
    return this.check(root, ["bookmark", "delete", name]).then(() => undefined);
192 +
  }
193 +
194 +
  merge(root: string, name: string): Promise<string> {
195 +
    return this.check(root, ["merge", name]).then((r) => r.stdout.toString("utf8").trim());
196 +
  }
197 +
198 +
  // ---------------------------------------------------------------- remotes and sync
199 +
200 +
  remotes(root: string): Promise<RemoteInfo[]> {
201 +
    return this.json<RemoteInfo[]>(root, ["remote", "list"]);
202 +
  }
203 +
204 +
  fetch(root: string, remote?: string): Promise<FetchInfo> {
205 +
    return this.json<FetchInfo>(root, ["fetch", ...(remote ? [remote] : [])]);
206 +
  }
207 +
208 +
  pull(root: string, remote?: string): Promise<PullInfo> {
209 +
    return this.json<PullInfo>(root, ["pull", ...(remote ? [remote] : [])]);
210 +
  }
211 +
212 +
  push(root: string, remote?: string, bookmark?: string, force = false): Promise<PushResult[]> {
213 +
    const args = ["push", ...(remote ? [remote] : [])];
214 +
    if (bookmark) {
215 +
      args.push("-b", bookmark);
216 +
    }
217 +
    if (force) {
218 +
      args.push("--force");
219 +
    }
220 +
    return this.json<PushResult[]>(root, args, [0, 1]);
221 +
  }
222 +
223 +
  clone(cwd: string, url: string, dest: string): Promise<void> {
224 +
    return this.check(cwd, ["clone", url, dest]).then(() => undefined);
225 +
  }
226 +
}
227 +
228 +
export interface RemoteInfo {
229 +
  name: string;
230 +
  url: string;
231 +
  bookmarks: { name: string; heads: string[] }[];
232 +
}
233 +
234 +
export interface FetchInfo {
235 +
  remote: string;
236 +
  refs: { name: string; heads: string[]; new: number }[];
237 +
  new_tags: number;
238 +
  unverified: boolean;
239 +
}
240 +
241 +
export interface PullInfo {
242 +
  remote: string;
243 +
  heads: string[];
244 +
  changed: boolean;
245 +
  checked_out: boolean;
246 +
  conflicted: boolean;
247 +
}
248 +
249 +
export interface PushResult {
250 +
  name: string;
251 +
  ok: boolean;
252 +
  reason: string;
175 253
}
176 254
177 255
export interface HunkInfo {
…
75 unchanged lines
modified src/repository.ts +28 −4
…
3 unchanged lines
4 4
5 5
import * as vscode from "vscode";
6 6
import { basename, join, relative } from "node:path";
7 -
import { Oxy, PathStatus, StatusInfo } from "./oxy";
7 +
import { BookmarkInfo, Oxy, PathStatus, StatusInfo } from "./oxy";
8 8
import { baseUri } from "./contentProvider";
9 9
10 10
const STATUS_LETTER: Record<PathStatus, string> = {
…
59 unchanged lines
70 70
  readonly conflictsGroup: vscode.SourceControlResourceGroup;
71 71
  readonly changesGroup: vscode.SourceControlResourceGroup;
72 72
  private readonly statusBar: vscode.StatusBarItem;
73 +
  private readonly syncBar: vscode.StatusBarItem;
74 +
  private bookmarksLatest: BookmarkInfo[] = [];
73 75
  private readonly disposables: vscode.Disposable[] = [];
74 76
  private readonly onDidChangeStatusEmitter = new vscode.EventEmitter<StatusInfo>();
75 77
  readonly onDidChangeStatus = this.onDidChangeStatusEmitter.event;
…
17 unchanged lines
93 95
    this.changesGroup = this.sourceControl.createResourceGroup("changes", "Changes");
94 96
    this.statusBar = vscode.window.createStatusBarItem("oxygen.status", vscode.StatusBarAlignment.Left, 100);
95 97
    this.statusBar.name = "Oxygen";
96 -
    this.statusBar.command = "oxygen.showLog";
97 -
    this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.statusBar, this.onDidChangeStatusEmitter);
98 +
    this.statusBar.command = { command: "oxygen.switchBookmark", title: "Switch bookmark", arguments: [this] };
99 +
    this.syncBar = vscode.window.createStatusBarItem("oxygen.sync", vscode.StatusBarAlignment.Left, 99);
100 +
    this.syncBar.name = "Oxygen sync";
101 +
    this.syncBar.command = { command: "oxygen.sync", title: "Sync", arguments: [this] };
102 +
    this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter);
98 103
99 104
    if (vscode.workspace.getConfiguration("oxygen").get<boolean>("autoRefresh", true)) {
100 105
      // Files in the workspace, except the repository's own store.
…
20 unchanged lines
121 126
    return this.latest;
122 127
  }
123 128
129 +
  get bookmarks(): BookmarkInfo[] {
130 +
    return this.bookmarksLatest;
131 +
  }
132 +
133 +
  get currentBookmark(): BookmarkInfo | undefined {
134 +
    return this.bookmarksLatest.find((b) => b.current);
135 +
  }
136 +
124 137
  /** The repository-relative path of a file under this root, or undefined. */
125 138
  relativePath(uri: vscode.Uri): string | undefined {
126 139
    if (uri.scheme !== "file") {
…
31 unchanged lines
158 171
159 172
  private async doRefresh(): Promise<void> {
160 173
    try {
161 -
      const [status, prompt] = await Promise.all([this.oxy.status(this.root), this.oxy.prompt(this.root)]);
174 +
      const [status, prompt, bookmarks] = await Promise.all([this.oxy.status(this.root), this.oxy.prompt(this.root), this.oxy.bookmarks(this.root).catch(() => [] as BookmarkInfo[])]);
162 175
      this.latest = status;
176 +
      this.bookmarksLatest = bookmarks;
163 177
      const conflicted = new Set(status.conflicts.map((c) => c.path).filter((p): p is string => !!p));
164 178
      const conflicts: OxyResourceState[] = [];
165 179
      const changes: OxyResourceState[] = [];
…
39 unchanged lines
205 219
    );
206 220
    this.statusBar.backgroundColor = status.conflicted ? new vscode.ThemeColor("statusBarItem.errorBackground") : undefined;
207 221
    this.statusBar.show();
222 +
    const current = this.currentBookmark;
223 +
    if (current && (current.ahead !== null || current.behind !== null)) {
224 +
      const ahead = current.ahead ?? 0;
225 +
      const behind = current.behind ?? 0;
226 +
      this.syncBar.text = `$(arrow-up)${ahead} $(arrow-down)${behind}`;
227 +
      this.syncBar.tooltip = `${ahead} change${ahead === 1 ? "" : "s"} to push, ${behind} to pull from origin. Click to sync.`;
228 +
      this.syncBar.show();
229 +
    } else {
230 +
      this.syncBar.hide();
231 +
    }
208 232
  }
209 233
210 234
  // QuickDiffProvider: the base version of a file, served from oxy.
…
21 unchanged lines
modified test/oxy.test.mjs +44 −0
…
69 unchanged lines
70 70
  status = await oxy.status(root);
71 71
  assert.ok(status.paths.some((p) => p.path === "b.txt"), "the commit is undone; b.txt is uncommitted work again");
72 72
});
73 +
74 +
test("bookmarks, remotes and sync against a local origin", async () => {
75 +
  const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-sync-"));
76 +
  const home = join(dir, "home");
77 +
  mkdirSync(home);
78 +
  process.env.OXY_HOME = home;
79 +
  await oxy.check(dir, ["init", "origin"]);
80 +
  await oxy.clone(dir, join(dir, "origin"), join(dir, "work"));
81 +
  const root = join(dir, "work");
82 +
  writeFileSync(join(root, "a.txt"), "a\n");
83 +
  await oxy.commit(root, "a");
84 +
  const remotes = await oxy.remotes(root);
85 +
  assert.equal(remotes[0].name, "origin");
86 +
  let push = await oxy.push(root);
87 +
  assert.equal(push[0].ok, true);
88 +
  let bookmarks = await oxy.bookmarks(root);
89 +
  assert.deepEqual(bookmarks.map((b) => [b.name, b.current, b.ahead, b.behind]), [["main", true, 0, 0]]);
90 +
91 +
  await oxy.bookmarkCreate(root, "feature");
92 +
  await oxy.bookmarkSwitch(root, "feature");
93 +
  writeFileSync(join(root, "a.txt"), "a\nfeature\n");
94 +
  await oxy.commit(root, "feature work");
95 +
  bookmarks = await oxy.bookmarks(root);
96 +
  assert.equal(bookmarks.find((b) => b.current)?.name, "feature");
97 +
  await oxy.bookmarkSwitch(root, "main");
98 +
  assert.equal((await oxy.cat(root, "a.txt")).toString(), "a\n");
99 +
  writeFileSync(join(root, "b.txt"), "b\n");
100 +
  await assert.rejects(oxy.bookmarkSwitch(root, "feature"), /has changes/);
101 +
  await oxy.commit(root, "b on main");
102 +
  assert.match(await oxy.merge(root, "feature"), /Merged/);
103 +
  assert.equal((await oxy.cat(root, "a.txt")).toString(), "a\nfeature\n");
104 +
  push = await oxy.push(root, "origin", "feature");
105 +
  assert.equal(push[0].ok, true);
106 +
  push = await oxy.push(root);
107 +
  assert.equal(push[0].ok, true);
108 +
  const fetch = await oxy.fetch(root);
109 +
  assert.equal(fetch.remote, "origin");
110 +
  const pull = await oxy.pull(root);
111 +
  assert.equal(pull.changed, false);
112 +
  await oxy.bookmarkDelete(root, "feature");
113 +
  bookmarks = await oxy.bookmarks(root);
114 +
  assert.deepEqual(bookmarks.map((b) => b.name), ["main"]);
115 +
});
116 +