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 | + |