Milestone 0.6: Subrepos group, Bisect and Trust views, verify, tags, add workspace, clone d2c9a5843004
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 16:02 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d2c9a584300473178e154b49af4792d7b4eb521fdd1f18138af0a664340828f8- Depends on
- 25103dddd993 35d56b5803d9 ab81294a9ba9 bed495a96408 c8f955891224
- Depended on by
- dac6876e4d08 b55e0d00545e 013fefef11e3 0c26846a9a81 f532c4266f21 b4626290e660 f6bda77ecb9f
- In bookmarks
- main
- In tags
- v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
- Edits
- 28 atoms, 1 tree operation, 7 files
Browse the tree at this change
added src/panels.ts +142 −0
| 1 | + | // The Bisect and Trust views (spec §5.11, §5.12): small trees fed by |
|
| 2 | + | // `oxy bisect status --json`, `oxy policy show --json` and `oxy verify --json`. |
|
| 3 | + | ||
| 4 | + | import * as vscode from "vscode"; |
|
| 5 | + | import { BisectStatus, PolicyInfo, VerifyInfo } from "./oxy"; |
|
| 6 | + | import { Repository } from "./repository"; |
|
| 7 | + | ||
| 8 | + | class Line extends vscode.TreeItem { |
|
| 9 | + | constructor(label: string, opts: { description?: string; icon?: string; command?: vscode.Command; tooltip?: string; context?: string } = {}) { |
|
| 10 | + | super(label, vscode.TreeItemCollapsibleState.None); |
|
| 11 | + | this.description = opts.description; |
|
| 12 | + | this.tooltip = opts.tooltip; |
|
| 13 | + | this.command = opts.command; |
|
| 14 | + | this.contextValue = opts.context; |
|
| 15 | + | if (opts.icon) { |
|
| 16 | + | this.iconPath = new vscode.ThemeIcon(opts.icon); |
|
| 17 | + | } |
|
| 18 | + | } |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | abstract class RepoTree implements vscode.TreeDataProvider<vscode.TreeItem> { |
|
| 22 | + | private readonly emitter = new vscode.EventEmitter<void>(); |
|
| 23 | + | readonly onDidChangeTreeData = this.emitter.event; |
|
| 24 | + | ||
| 25 | + | constructor(protected readonly repositories: () => Repository[]) {} |
|
| 26 | + | ||
| 27 | + | refresh(): void { |
|
| 28 | + | this.emitter.fire(); |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | getTreeItem(element: vscode.TreeItem): vscode.TreeItem { |
|
| 32 | + | return element; |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | async getChildren(element?: vscode.TreeItem): Promise<vscode.TreeItem[]> { |
|
| 36 | + | const repos = this.repositories(); |
|
| 37 | + | if (!element) { |
|
| 38 | + | if (repos.length === 1) { |
|
| 39 | + | return this.items(repos[0]); |
|
| 40 | + | } |
|
| 41 | + | return repos.map((r) => { |
|
| 42 | + | const item = new vscode.TreeItem(r.root.split("/").pop() ?? r.root, vscode.TreeItemCollapsibleState.Expanded); |
|
| 43 | + | item.iconPath = new vscode.ThemeIcon("repo"); |
|
| 44 | + | (item as vscode.TreeItem & { repository?: Repository }).repository = r; |
|
| 45 | + | return item; |
|
| 46 | + | }); |
|
| 47 | + | } |
|
| 48 | + | const repo = (element as vscode.TreeItem & { repository?: Repository }).repository; |
|
| 49 | + | return repo ? this.items(repo) : []; |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | protected abstract items(repo: Repository): Promise<vscode.TreeItem[]>; |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | export class BisectProvider extends RepoTree { |
|
| 56 | + | protected async items(repo: Repository): Promise<vscode.TreeItem[]> { |
|
| 57 | + | let status: BisectStatus | null; |
|
| 58 | + | try { |
|
| 59 | + | status = await repo.oxy.bisectStatus(repo.root); |
|
| 60 | + | } catch (e) { |
|
| 61 | + | return [new Line(String(e), { icon: "warning" })]; |
|
| 62 | + | } |
|
| 63 | + | const cmd = (title: string, command: string, args: unknown[] = []): vscode.Command => ({ title, command, arguments: [repo, ...args] }); |
|
| 64 | + | if (!status) { |
|
| 65 | + | return [new Line("No bisect in progress", { icon: "search", description: "start one to find a regression", command: cmd("Start", "oxygen.bisectStart") })]; |
|
| 66 | + | } |
|
| 67 | + | switch (status.state) { |
|
| 68 | + | case "need-marks": |
|
| 69 | + | return [ |
|
| 70 | + | new Line(`Marked ${status.good} good, ${status.bad} bad`, { icon: "search" }), |
|
| 71 | + | new Line(status.good === 0 ? "Mark a known-good change or bookmark…" : "Mark a known-bad change or bookmark…", { icon: status.good === 0 ? "pass" : "error", command: cmd("Mark", status.good === 0 ? "oxygen.bisectGood" : "oxygen.bisectBad") }), |
|
| 72 | + | new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), |
|
| 73 | + | ]; |
|
| 74 | + | case "testing": |
|
| 75 | + | return [ |
|
| 76 | + | new Line(`Testing: ${status.remaining} candidates left`, { icon: "beaker", description: `about ${status.steps_left} more steps` }), |
|
| 77 | + | new Line("This state is good", { icon: "pass", command: cmd("Good", "oxygen.bisectGood", [[]]) }), |
|
| 78 | + | new Line("This state is bad", { icon: "error", command: cmd("Bad", "oxygen.bisectBad", [[]]) }), |
|
| 79 | + | new Line("Skip: cannot test this state", { icon: "debug-step-over", command: cmd("Skip", "oxygen.bisectSkip") }), |
|
| 80 | + | new Line("Run a command for every step…", { icon: "run-all", command: cmd("Run", "oxygen.bisectRun") }), |
|
| 81 | + | new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), |
|
| 82 | + | ]; |
|
| 83 | + | case "found": |
|
| 84 | + | return [ |
|
| 85 | + | new Line(`Found: ${status.culprit.slice(0, 12)}`, { icon: "bug", description: "the first change that makes the state bad", command: { title: "Show", command: "oxygen.showChange", arguments: [repo.root, status.culprit] } }), |
|
| 86 | + | new Line("Reset: back to where you were", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), |
|
| 87 | + | ]; |
|
| 88 | + | case "inconclusive": |
|
| 89 | + | return [ |
|
| 90 | + | new Line(`Inconclusive: ${status.candidates.length} candidates could not be told apart`, { icon: "question" }), |
|
| 91 | + | ...status.candidates.map((c) => new Line(c.slice(0, 12), { icon: "circle-outline", command: { title: "Show", command: "oxygen.showChange", arguments: [repo.root, c] } })), |
|
| 92 | + | new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), |
|
| 93 | + | ]; |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | export class TrustProvider extends RepoTree { |
|
| 99 | + | protected async items(repo: Repository): Promise<vscode.TreeItem[]> { |
|
| 100 | + | let policy: PolicyInfo | null; |
|
| 101 | + | let verify: VerifyInfo[]; |
|
| 102 | + | try { |
|
| 103 | + | [policy, verify] = await Promise.all([repo.oxy.policyShow(repo.root), repo.oxy.verify(repo.root)]); |
|
| 104 | + | } catch (e) { |
|
| 105 | + | return [new Line(String(e), { icon: "warning" })]; |
|
| 106 | + | } |
|
| 107 | + | const items: vscode.TreeItem[] = []; |
|
| 108 | + | if (!policy) { |
|
| 109 | + | items.push(new Line("No trust policy: this repository is unverified", { icon: "unlock", description: "oxy policy init creates one", tooltip: "Without a policy every change and reference update is accepted." })); |
|
| 110 | + | } else { |
|
| 111 | + | const expires = new Date(policy.expires_ms).toLocaleDateString(); |
|
| 112 | + | items.push(new Line(`Policy serial ${policy.serial}`, { icon: policy.expired ? "error" : "shield", description: policy.expired ? `EXPIRED ${expires}` : `expires ${expires}` })); |
|
| 113 | + | for (const r of policy.roots) { |
|
| 114 | + | items.push(new Line(`Root: ${r.name || r.key.slice(0, 12)}`, { icon: "key", description: r.email, tooltip: r.key })); |
|
| 115 | + | } |
|
| 116 | + | items.push(new Line(`Root threshold ${policy.root_threshold}`, { icon: "law" })); |
|
| 117 | + | for (const rule of policy.rules) { |
|
| 118 | + | items.push(new Line(`Rule ${rule.pattern}`, { icon: "list-tree", description: `${rule.keys.map((k) => k.name || k.key.slice(0, 8)).join(", ")} · threshold ${rule.threshold} · ${rule.require}${rule.allow_conflicts ? "" : " · no conflicts"}` })); |
|
| 119 | + | } |
|
| 120 | + | for (const r of policy.revoked) { |
|
| 121 | + | items.push(new Line(`Revoked: ${r.name || r.key.slice(0, 12)}`, { icon: "circle-slash" })); |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | const counts = { signed: 0, sealed: 0, unsigned: 0, invalid: 0, revoked: 0 }; |
|
| 125 | + | for (const v of verify) { |
|
| 126 | + | if (v.status in counts) { |
|
| 127 | + | counts[v.status as keyof typeof counts] += 1; |
|
| 128 | + | } |
|
| 129 | + | if (v.revoked) { |
|
| 130 | + | counts.revoked += 1; |
|
| 131 | + | } |
|
| 132 | + | } |
|
| 133 | + | items.push( |
|
| 134 | + | new Line(`${verify.length} changes verified`, { |
|
| 135 | + | icon: counts.invalid > 0 ? "error" : "verified", |
|
| 136 | + | description: `${counts.signed} signed, ${counts.sealed} sealed, ${counts.unsigned} unsigned${counts.invalid ? `, ${counts.invalid} INVALID` : ""}${counts.revoked ? `, ${counts.revoked} by revoked keys` : ""}`, |
|
| 137 | + | command: { title: "Verify", command: "oxygen.verify", arguments: [repo] }, |
|
| 138 | + | }), |
|
| 139 | + | ); |
|
| 140 | + | return items; |
|
| 141 | + | } |
|
| 142 | + | } |
modified CHANGELOG.md +4 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.6.0 (2026-09-27) |
|
| 4 | + | ||
| 5 | + | Milestone 0.6: a Subrepos group with update and open, Oxygen Bisect and Oxygen Trust views, Verify Signatures, tag picker and Create Tag, Add Workspace, and Clone Repository. |
|
| 6 | + | ||
| 3 | 7 | ## 0.5.0 (2026-09-27) |
|
| 4 | 8 | ||
| 5 | 9 | 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. |
|
| … | 28 unchanged lines |
modified package.json +35 −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.5.0", |
|
| 5 | + | "version": "0.6.0", |
|
| 6 | 6 | "publisher": "oxygen", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygen.png", |
|
| … | 48 unchanged lines |
||
| 57 | 57 | { "command": "oxygen.acceptSides", "title": "Accept Conflict Sides", "category": "Oxygen" }, |
|
| 58 | 58 | { "command": "oxygen.keepCandidate", "title": "Keep This Candidate", "category": "Oxygen", "icon": "$(check)" }, |
|
| 59 | 59 | { "command": "oxygen.markResolved", "title": "Mark Resolved", "category": "Oxygen", "icon": "$(check-all)" }, |
|
| 60 | - | { "command": "oxygen.openConflict", "title": "Open Conflict", "category": "Oxygen" } |
|
| 60 | + | { "command": "oxygen.openConflict", "title": "Open Conflict", "category": "Oxygen" }, |
|
| 61 | + | { "command": "oxygen.openSubrepo", "title": "Open Subrepo", "category": "Oxygen" }, |
|
| 62 | + | { "command": "oxygen.updateSubrepo", "title": "Update to Pin", "category": "Oxygen", "icon": "$(cloud-download)" }, |
|
| 63 | + | { "command": "oxygen.updateSubrepos", "title": "Update Subrepos", "category": "Oxygen" }, |
|
| 64 | + | { "command": "oxygen.bisectStart", "title": "Bisect: Start", "category": "Oxygen" }, |
|
| 65 | + | { "command": "oxygen.bisectGood", "title": "Bisect: Good", "category": "Oxygen" }, |
|
| 66 | + | { "command": "oxygen.bisectBad", "title": "Bisect: Bad", "category": "Oxygen" }, |
|
| 67 | + | { "command": "oxygen.bisectSkip", "title": "Bisect: Skip", "category": "Oxygen" }, |
|
| 68 | + | { "command": "oxygen.bisectRun", "title": "Bisect: Run…", "category": "Oxygen" }, |
|
| 69 | + | { "command": "oxygen.bisectReset", "title": "Bisect: Reset", "category": "Oxygen" }, |
|
| 70 | + | { "command": "oxygen.refreshBisect", "title": "Refresh", "category": "Oxygen", "icon": "$(refresh)" }, |
|
| 71 | + | { "command": "oxygen.refreshTrust", "title": "Refresh", "category": "Oxygen", "icon": "$(refresh)" }, |
|
| 72 | + | { "command": "oxygen.verify", "title": "Verify Signatures", "category": "Oxygen" }, |
|
| 73 | + | { "command": "oxygen.tags", "title": "Tags…", "category": "Oxygen" }, |
|
| 74 | + | { "command": "oxygen.createTag", "title": "Create Tag…", "category": "Oxygen" }, |
|
| 75 | + | { "command": "oxygen.addWorkspace", "title": "Add Workspace…", "category": "Oxygen" }, |
|
| 76 | + | { "command": "oxygen.clone", "title": "Clone Repository…", "category": "Oxygen" } |
|
| 61 | 77 | ], |
|
| 62 | 78 | "views": { |
|
| 63 | 79 | "scm": [ |
|
| 64 | - | { "id": "oxygen.operations", "name": "Oxygen Operations", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" } |
|
| 80 | + | { "id": "oxygen.operations", "name": "Oxygen Operations", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" }, |
|
| 81 | + | { "id": "oxygen.bisect", "name": "Oxygen Bisect", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" }, |
|
| 82 | + | { "id": "oxygen.trust", "name": "Oxygen Trust", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" } |
|
| 65 | 83 | ] |
|
| 66 | 84 | }, |
|
| 67 | 85 | "viewsWelcome": [ |
|
| … | 16 unchanged lines |
||
| 84 | 102 | { "command": "oxygen.createBookmark", "group": "5_bookmarks@2", "when": "scmProvider == oxygen" }, |
|
| 85 | 103 | { "command": "oxygen.mergeBookmark", "group": "5_bookmarks@3", "when": "scmProvider == oxygen" }, |
|
| 86 | 104 | { "command": "oxygen.deleteBookmark", "group": "5_bookmarks@4", "when": "scmProvider == oxygen" }, |
|
| 105 | + | { "command": "oxygen.tags", "group": "6_more@1", "when": "scmProvider == oxygen" }, |
|
| 106 | + | { "command": "oxygen.createTag", "group": "6_more@2", "when": "scmProvider == oxygen" }, |
|
| 107 | + | { "command": "oxygen.addWorkspace", "group": "6_more@3", "when": "scmProvider == oxygen" }, |
|
| 108 | + | { "command": "oxygen.updateSubrepos", "group": "6_more@4", "when": "scmProvider == oxygen" }, |
|
| 109 | + | { "command": "oxygen.verify", "group": "6_more@5", "when": "scmProvider == oxygen" }, |
|
| 87 | 110 | { "command": "oxygen.commitHunks", "group": "2_commit@1", "when": "scmProvider == oxygen" }, |
|
| 88 | 111 | { "command": "oxygen.amend", "group": "2_commit@2", "when": "scmProvider == oxygen" }, |
|
| 89 | 112 | { "command": "oxygen.undo", "group": "3_undo", "when": "scmProvider == oxygen" }, |
|
| … | 20 unchanged lines |
||
| 110 | 133 | { "command": "oxygen.commitSelected", "group": "1_modification@1", "when": "scmProvider == oxygen" }, |
|
| 111 | 134 | { "command": "oxygen.discard", "group": "1_modification@2", "when": "scmProvider == oxygen" }, |
|
| 112 | 135 | { "command": "oxygen.keepCandidate", "group": "inline@0", "when": "scmProvider == oxygen && scmResourceState == candidate" }, |
|
| 136 | + | { "command": "oxygen.updateSubrepo", "group": "inline@0", "when": "scmProvider == oxygen && scmResourceState == subrepo" }, |
|
| 113 | 137 | { "command": "oxygen.keepCandidate", "group": "0_conflict", "when": "scmProvider == oxygen && scmResourceState == candidate" } |
|
| 114 | 138 | ], |
|
| 115 | 139 | "scm/resourceGroup/context": [ |
|
| 116 | 140 | { "command": "oxygen.markResolved", "group": "inline", "when": "scmProvider == oxygen && scmResourceGroup == conflicts" } |
|
| 117 | 141 | ], |
|
| 118 | 142 | "view/title": [ |
|
| 119 | - | { "command": "oxygen.refreshOperations", "group": "navigation", "when": "view == oxygen.operations" } |
|
| 143 | + | { "command": "oxygen.refreshOperations", "group": "navigation", "when": "view == oxygen.operations" }, |
|
| 144 | + | { "command": "oxygen.refreshBisect", "group": "navigation", "when": "view == oxygen.bisect" }, |
|
| 145 | + | { "command": "oxygen.refreshTrust", "group": "navigation", "when": "view == oxygen.trust" } |
|
| 120 | 146 | ], |
|
| 121 | 147 | "view/item/context": [ |
|
| 122 | 148 | { "command": "oxygen.undoTo", "group": "inline", "when": "view == oxygen.operations && viewItem == operation" } |
|
| … | 9 unchanged lines |
||
| 132 | 158 | { "command": "oxygen.showChange", "when": "false" }, |
|
| 133 | 159 | { "command": "oxygen.acceptSides", "when": "false" }, |
|
| 134 | 160 | { "command": "oxygen.keepCandidate", "when": "false" }, |
|
| 135 | - | { "command": "oxygen.openConflict", "when": "false" } |
|
| 161 | + | { "command": "oxygen.openConflict", "when": "false" }, |
|
| 162 | + | { "command": "oxygen.openSubrepo", "when": "false" }, |
|
| 163 | + | { "command": "oxygen.updateSubrepo", "when": "false" }, |
|
| 164 | + | { "command": "oxygen.refreshBisect", "when": "false" }, |
|
| 165 | + | { "command": "oxygen.refreshTrust", "when": "false" } |
|
| 136 | 166 | ] |
|
| 137 | 167 | }, |
|
| 138 | 168 | "configuration": { |
|
| … | 57 unchanged lines |
modified src/extension.ts +232 −1
| … | 3 unchanged lines |
||
| 4 | 4 | import * as vscode from "vscode"; |
|
| 5 | 5 | import { basename } from "node:path"; |
|
| 6 | 6 | import { Oxy, OxyError, findBinary } from "./oxy"; |
|
| 7 | - | import { Repository, OxyResourceState } from "./repository"; |
|
| 7 | + | import { Repository, OxyResourceState, SubrepoState } from "./repository"; |
|
| 8 | + | import { BisectProvider, TrustProvider } from "./panels"; |
|
| 8 | 9 | import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider"; |
|
| 9 | 10 | import { OxyDecorationProvider } from "./decorations"; |
|
| 10 | 11 | import { OperationItem, OperationsProvider, timeAgo } from "./operations"; |
|
| … | 10 unchanged lines |
||
| 21 | 22 | let operations: OperationsProvider; |
|
| 22 | 23 | let blame: BlameController; |
|
| 23 | 24 | let conflictLenses: ConflictCodeLensProvider; |
|
| 25 | + | let bisectView: BisectProvider; |
|
| 26 | + | let trustView: TrustProvider; |
|
| 24 | 27 | const repositories = new Map<string, Repository>(); |
|
| 25 | 28 | ||
| 26 | 29 | export async function activate(context: vscode.ExtensionContext): Promise<void> { |
|
| … | 26 unchanged lines |
||
| 53 | 56 | context.subscriptions.push(blame); |
|
| 54 | 57 | conflictLenses = new ConflictCodeLensProvider(() => [...repositories.values()]); |
|
| 55 | 58 | context.subscriptions.push(vscode.languages.registerCodeLensProvider({ scheme: "file" }, conflictLenses)); |
|
| 59 | + | bisectView = new BisectProvider(() => [...repositories.values()]); |
|
| 60 | + | trustView = new TrustProvider(() => [...repositories.values()]); |
|
| 61 | + | context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.bisect", bisectView), vscode.window.registerTreeDataProvider("oxygen.trust", trustView)); |
|
| 56 | 62 | ||
| 57 | 63 | context.subscriptions.push( |
|
| 58 | 64 | vscode.commands.registerCommand("oxygen.refresh", async () => { |
|
| … | 38 unchanged lines |
||
| 97 | 103 | return repo && markResolved(repo).catch(showError); |
|
| 98 | 104 | }), |
|
| 99 | 105 | vscode.commands.registerCommand("oxygen.openConflict", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)), |
|
| 106 | + | vscode.commands.registerCommand("oxygen.openSubrepo", (state?: SubrepoState) => state && openSubrepo(state)), |
|
| 107 | + | vscode.commands.registerCommand("oxygen.updateSubrepo", (state?: SubrepoState) => state && updateSubrepos(state.repository, true, [state.info.path])), |
|
| 108 | + | vscode.commands.registerCommand("oxygen.updateSubrepos", (arg?: Repository | vscode.SourceControl) => { |
|
| 109 | + | const repo = repositoryFor(arg); |
|
| 110 | + | return repo && updateSubrepos(repo, true, []); |
|
| 111 | + | }), |
|
| 112 | + | vscode.commands.registerCommand("oxygen.bisectStart", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectStart(r.root).then(() => "Started; mark a good and a bad change"))), |
|
| 113 | + | vscode.commands.registerCommand("oxygen.bisectGood", (arg?: Repository, changes?: string[]) => bisectMark(arg, "good", changes)), |
|
| 114 | + | vscode.commands.registerCommand("oxygen.bisectBad", (arg?: Repository, changes?: string[]) => bisectMark(arg, "bad", changes)), |
|
| 115 | + | vscode.commands.registerCommand("oxygen.bisectSkip", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectMark(r.root, "skip"))), |
|
| 116 | + | vscode.commands.registerCommand("oxygen.bisectRun", (arg?: Repository) => bisectRun(arg)), |
|
| 117 | + | vscode.commands.registerCommand("oxygen.bisectReset", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectReset(r.root).then(() => "Bisect reset; working copy restored"))), |
|
| 118 | + | vscode.commands.registerCommand("oxygen.refreshBisect", () => bisectView.refresh()), |
|
| 119 | + | vscode.commands.registerCommand("oxygen.refreshTrust", () => trustView.refresh()), |
|
| 120 | + | vscode.commands.registerCommand("oxygen.verify", (arg?: Repository | vscode.SourceControl) => verify(arg)), |
|
| 121 | + | vscode.commands.registerCommand("oxygen.tags", (arg?: Repository | vscode.SourceControl) => tags(arg)), |
|
| 122 | + | vscode.commands.registerCommand("oxygen.createTag", (arg?: Repository | vscode.SourceControl) => createTag(arg)), |
|
| 123 | + | vscode.commands.registerCommand("oxygen.addWorkspace", (arg?: Repository | vscode.SourceControl) => addWorkspace(arg)), |
|
| 124 | + | vscode.commands.registerCommand("oxygen.clone", () => cloneRepository()), |
|
| 100 | 125 | vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()), |
|
| 101 | 126 | vscode.workspace.onDidChangeConfiguration((e) => { |
|
| 102 | 127 | if (e.affectsConfiguration("oxygen.path")) { |
|
| … | 47 unchanged lines |
||
| 150 | 175 | blame.invalidate(root); |
|
| 151 | 176 | HistoryPanel.refresh(root); |
|
| 152 | 177 | conflictLenses.refresh(); |
|
| 178 | + | bisectView.refresh(); |
|
| 179 | + | trustView.refresh(); |
|
| 153 | 180 | // The commit action reads as a resolution while conflicts are resolved but uncommitted. |
|
| 154 | 181 | const resolved = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true); |
|
| 155 | 182 | repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: resolved ? "Commit resolution" : "Commit", arguments: [repo] }; |
|
| … | 194 unchanged lines |
||
| 350 | 377 | } |
|
| 351 | 378 | } |
|
| 352 | 379 | ||
| 380 | + | // ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces, clone |
|
| 381 | + | ||
| 382 | + | async function openSubrepo(state: SubrepoState): Promise<void> { |
|
| 383 | + | if (!state.info.actual) { |
|
| 384 | + | const pick = await vscode.window.showInformationMessage(`${state.info.path} is not checked out yet.`, "Update --init"); |
|
| 385 | + | if (pick) { |
|
| 386 | + | await updateSubrepos(state.repository, true, [state.info.path]); |
|
| 387 | + | } |
|
| 388 | + | return; |
|
| 389 | + | } |
|
| 390 | + | const pick = await vscode.window.showQuickPick( |
|
| 391 | + | [ |
|
| 392 | + | { label: "$(folder-opened) Open in new window", action: "window" }, |
|
| 393 | + | { label: "$(cloud-download) Update to the pin", action: "update", description: state.info.at_pin ? "already at pin" : "moves the nested repository" }, |
|
| 394 | + | ], |
|
| 395 | + | { placeHolder: `${state.info.path} · ${state.decorations.tooltip}` }, |
|
| 396 | + | ); |
|
| 397 | + | if (pick?.action === "window") { |
|
| 398 | + | await vscode.commands.executeCommand("vscode.openFolder", state.resourceUri, { forceNewWindow: true }); |
|
| 399 | + | } else if (pick?.action === "update") { |
|
| 400 | + | await updateSubrepos(state.repository, true, [state.info.path]); |
|
| 401 | + | } |
|
| 402 | + | } |
|
| 403 | + | ||
| 404 | + | async function updateSubrepos(repo: Repository, init: boolean, paths: string[]): Promise<void> { |
|
| 405 | + | try { |
|
| 406 | + | const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Updating subrepos" }, () => repo.oxy.submoduleUpdate(repo.root, init, paths)); |
|
| 407 | + | await repo.refresh(); |
|
| 408 | + | const lines = out.trim().split("\n").filter(Boolean); |
|
| 409 | + | if (lines.some((l) => /missing|uncommitted|✖|x /.test(l))) { |
|
| 410 | + | void vscode.window.showWarningMessage(`Oxygen: ${lines.join("; ")}`); |
|
| 411 | + | } else { |
|
| 412 | + | vscode.window.setStatusBarMessage(`$(check) ${lines.join("; ") || "Subrepos up to date"}`, 5000); |
|
| 413 | + | } |
|
| 414 | + | } catch (e) { |
|
| 415 | + | showError(e); |
|
| 416 | + | } |
|
| 417 | + | } |
|
| 418 | + | ||
| 419 | + | async function bisect(arg: Repository | undefined, run: (repo: Repository) => Promise<string>): Promise<void> { |
|
| 420 | + | const repo = arg instanceof Repository ? arg : repositoryFor(undefined); |
|
| 421 | + | if (!repo) { |
|
| 422 | + | return; |
|
| 423 | + | } |
|
| 424 | + | try { |
|
| 425 | + | const out = await run(repo); |
|
| 426 | + | await repo.refresh(); |
|
| 427 | + | vscode.window.setStatusBarMessage(`$(search) ${out.split("\n")[0]}`, 5000); |
|
| 428 | + | } catch (e) { |
|
| 429 | + | showError(e); |
|
| 430 | + | } |
|
| 431 | + | } |
|
| 432 | + | ||
| 433 | + | async function bisectMark(arg: Repository | undefined, verdict: "good" | "bad", changes?: string[]): Promise<void> { |
|
| 434 | + | const repo = arg instanceof Repository ? arg : repositoryFor(undefined); |
|
| 435 | + | if (!repo) { |
|
| 436 | + | return; |
|
| 437 | + | } |
|
| 438 | + | let picked = changes; |
|
| 439 | + | if (picked === undefined) { |
|
| 440 | + | const status = await repo.oxy.bisectStatus(repo.root).catch(() => null); |
|
| 441 | + | if (!status || status.state === "need-marks") { |
|
| 442 | + | const value = await vscode.window.showInputBox({ prompt: `A change, bookmark or tag known to be ${verdict}`, placeHolder: verdict === "good" ? "v1.2 or a ChangeId prefix" : "main" }); |
|
| 443 | + | if (!value?.trim()) { |
|
| 444 | + | return; |
|
| 445 | + | } |
|
| 446 | + | picked = value.trim().split(/\s+/); |
|
| 447 | + | } else { |
|
| 448 | + | picked = []; |
|
| 449 | + | } |
|
| 450 | + | } |
|
| 451 | + | await bisect(repo, (r) => r.oxy.bisectMark(r.root, verdict, picked)); |
|
| 452 | + | } |
|
| 453 | + | ||
| 454 | + | async function bisectRun(arg: Repository | undefined): Promise<void> { |
|
| 455 | + | const repo = arg instanceof Repository ? arg : repositoryFor(undefined); |
|
| 456 | + | if (!repo) { |
|
| 457 | + | return; |
|
| 458 | + | } |
|
| 459 | + | const value = await vscode.window.showInputBox({ prompt: "Command to run for each step (exit 0 good, 125 skip, anything else bad)", placeHolder: "make test" }); |
|
| 460 | + | if (!value?.trim()) { |
|
| 461 | + | return; |
|
| 462 | + | } |
|
| 463 | + | await bisect(repo, (r) => Promise.resolve(vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Oxygen: bisecting", cancellable: false }, () => r.oxy.bisectRun(r.root, ["sh", "-c", value.trim()])))); |
|
| 464 | + | } |
|
| 465 | + | ||
| 466 | + | async function verify(arg: Repository | vscode.SourceControl | undefined): Promise<void> { |
|
| 467 | + | const repo = repositoryFor(arg); |
|
| 468 | + | if (!repo) { |
|
| 469 | + | return; |
|
| 470 | + | } |
|
| 471 | + | try { |
|
| 472 | + | const results = await repo.oxy.verify(repo.root); |
|
| 473 | + | const icon = (s: string, revoked: boolean) => (s === "invalid" ? "$(error)" : s === "unsigned" ? "$(circle-outline)" : revoked ? "$(warning)" : "$(verified)"); |
|
| 474 | + | const pick = await vscode.window.showQuickPick( |
|
| 475 | + | results.map((r) => ({ |
|
| 476 | + | label: `${icon(r.status, r.revoked)} ${r.change_id.slice(0, 8)} ${r.status}${r.revoked ? " (key revoked)" : ""}`, |
|
| 477 | + | description: r.name ?? "", |
|
| 478 | + | detail: r.reason ?? undefined, |
|
| 479 | + | hash: r.hash, |
|
| 480 | + | })), |
|
| 481 | + | { placeHolder: `${results.length} changes; ${results.filter((r) => r.status === "invalid").length} invalid`, matchOnDescription: true }, |
|
| 482 | + | ); |
|
| 483 | + | if (pick) { |
|
| 484 | + | await showChange(repo.root, pick.hash); |
|
| 485 | + | } |
|
| 486 | + | } catch (e) { |
|
| 487 | + | showError(e); |
|
| 488 | + | } |
|
| 489 | + | } |
|
| 490 | + | ||
| 491 | + | async function tags(arg: Repository | vscode.SourceControl | undefined): Promise<void> { |
|
| 492 | + | const repo = repositoryFor(arg); |
|
| 493 | + | if (!repo) { |
|
| 494 | + | return; |
|
| 495 | + | } |
|
| 496 | + | try { |
|
| 497 | + | const list = await repo.oxy.tags(repo.root); |
|
| 498 | + | if (list.length === 0) { |
|
| 499 | + | const pick = await vscode.window.showInformationMessage("Oxygen: no tags yet.", "Create tag"); |
|
| 500 | + | if (pick) { |
|
| 501 | + | await createTag(repo); |
|
| 502 | + | } |
|
| 503 | + | return; |
|
| 504 | + | } |
|
| 505 | + | const pick = await vscode.window.showQuickPick( |
|
| 506 | + | list.map((t) => ({ label: `$(tag) ${t.name}`, description: `${t.heads.length} head${t.heads.length === 1 ? "" : "s"}${t.signer ? ` · signed by ${t.signer}` : ""}`, detail: t.message || undefined, tag: t })), |
|
| 507 | + | { placeHolder: "Check out a tag's state" }, |
|
| 508 | + | ); |
|
| 509 | + | if (!pick) { |
|
| 510 | + | return; |
|
| 511 | + | } |
|
| 512 | + | await repo.oxy.checkout(repo.root, [pick.tag.name]); |
|
| 513 | + | await repo.refresh(); |
|
| 514 | + | } catch (e) { |
|
| 515 | + | if (e instanceof OxyError && e.message.includes("has changes")) { |
|
| 516 | + | void vscode.window.showWarningMessage("Oxygen: commit or discard your changes before checking out a tag."); |
|
| 517 | + | return; |
|
| 518 | + | } |
|
| 519 | + | showError(e); |
|
| 520 | + | } |
|
| 521 | + | } |
|
| 522 | + | ||
| 523 | + | async function createTag(arg: Repository | vscode.SourceControl | undefined): Promise<void> { |
|
| 524 | + | const repo = repositoryFor(arg); |
|
| 525 | + | if (!repo) { |
|
| 526 | + | return; |
|
| 527 | + | } |
|
| 528 | + | const name = await vscode.window.showInputBox({ prompt: "Tag name (tags the current bookmark's heads)" }); |
|
| 529 | + | if (!name?.trim()) { |
|
| 530 | + | return; |
|
| 531 | + | } |
|
| 532 | + | const message = (await vscode.window.showInputBox({ prompt: "Tag message (optional)" })) ?? ""; |
|
| 533 | + | try { |
|
| 534 | + | await repo.oxy.tagCreate(repo.root, name.trim(), [], message); |
|
| 535 | + | await repo.refresh(); |
|
| 536 | + | vscode.window.setStatusBarMessage(`$(tag) Tagged ${name.trim()}`, 4000); |
|
| 537 | + | } catch (e) { |
|
| 538 | + | showError(e); |
|
| 539 | + | } |
|
| 540 | + | } |
|
| 541 | + | ||
| 542 | + | async function addWorkspace(arg: Repository | vscode.SourceControl | undefined): Promise<void> { |
|
| 543 | + | const repo = repositoryFor(arg); |
|
| 544 | + | if (!repo) { |
|
| 545 | + | return; |
|
| 546 | + | } |
|
| 547 | + | const folders = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Create workspace here", title: "Folder for the new workspace (must be empty or absent)" }); |
|
| 548 | + | if (!folders?.[0]) { |
|
| 549 | + | return; |
|
| 550 | + | } |
|
| 551 | + | try { |
|
| 552 | + | await repo.oxy.workspaceAdd(repo.root, folders[0].fsPath); |
|
| 553 | + | const pick = await vscode.window.showInformationMessage(`Oxygen: workspace created at ${folders[0].fsPath}.`, "Open in new window"); |
|
| 554 | + | if (pick) { |
|
| 555 | + | await vscode.commands.executeCommand("vscode.openFolder", folders[0], { forceNewWindow: true }); |
|
| 556 | + | } |
|
| 557 | + | } catch (e) { |
|
| 558 | + | showError(e); |
|
| 559 | + | } |
|
| 560 | + | } |
|
| 561 | + | ||
| 562 | + | async function cloneRepository(): Promise<void> { |
|
| 563 | + | const url = await vscode.window.showInputBox({ prompt: "Repository to clone (path or https URL)", placeHolder: "https://host:8890/project" }); |
|
| 564 | + | if (!url?.trim()) { |
|
| 565 | + | return; |
|
| 566 | + | } |
|
| 567 | + | const parent = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Clone into this folder" }); |
|
| 568 | + | if (!parent?.[0]) { |
|
| 569 | + | return; |
|
| 570 | + | } |
|
| 571 | + | const name = url.trim().replace(/\/+$/, "").split("/").pop()?.replace(/\.oxy$/, "") || "repository"; |
|
| 572 | + | const dest = vscode.Uri.joinPath(parent[0], name); |
|
| 573 | + | try { |
|
| 574 | + | await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: cloning ${name}` }, () => oxy.clone(parent[0].fsPath, url.trim(), dest.fsPath)); |
|
| 575 | + | const pick = await vscode.window.showInformationMessage(`Oxygen: cloned into ${dest.fsPath}.`, "Open", "Open in new window"); |
|
| 576 | + | if (pick) { |
|
| 577 | + | await vscode.commands.executeCommand("vscode.openFolder", dest, { forceNewWindow: pick === "Open in new window" }); |
|
| 578 | + | } |
|
| 579 | + | } catch (e) { |
|
| 580 | + | showError(e); |
|
| 581 | + | } |
|
| 582 | + | } |
|
| 583 | + | ||
| 353 | 584 | // ---------------------------------------------------------------- history |
|
| 354 | 585 | ||
| 355 | 586 | async function showChange(root: string, hash: string): Promise<void> { |
|
| … | 444 unchanged lines |
modified src/oxy.ts +103 −0
| … | 253 unchanged lines |
||
| 254 | 254 | tagCreate(root: string, name: string, changes: string[] = [], message = ""): Promise<void> { |
|
| 255 | 255 | return this.check(root, ["tag", "create", name, ...changes, "-m", message]).then(() => undefined); |
|
| 256 | 256 | } |
|
| 257 | + | ||
| 258 | + | // ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces |
|
| 259 | + | ||
| 260 | + | submodules(root: string): Promise<SubrepoInfo[]> { |
|
| 261 | + | return this.json<SubrepoInfo[]>(root, ["submodule", "status"]); |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | submoduleUpdate(root: string, init: boolean, paths: string[] = []): Promise<string> { |
|
| 265 | + | return this.check(root, ["submodule", "update", ...(init ? ["--init"] : []), ...paths], [0, 1]).then((r) => r.stdout.toString("utf8")); |
|
| 266 | + | } |
|
| 267 | + | ||
| 268 | + | bisectStatus(root: string): Promise<BisectStatus | null> { |
|
| 269 | + | return this.json<BisectStatus | null>(root, ["bisect", "status"]); |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | bisectStart(root: string): Promise<void> { |
|
| 273 | + | return this.check(root, ["bisect", "start"]).then(() => undefined); |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | bisectMark(root: string, verdict: "good" | "bad" | "skip", changes: string[] = []): Promise<string> { |
|
| 277 | + | return this.check(root, ["bisect", verdict, ...changes]).then((r) => r.stdout.toString("utf8").trim()); |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | bisectRun(root: string, command: string[]): Promise<string> { |
|
| 281 | + | return this.check(root, ["bisect", "run", ...command], [0, 1]).then((r) => r.stdout.toString("utf8").trim()); |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | bisectReset(root: string): Promise<void> { |
|
| 285 | + | return this.check(root, ["bisect", "reset"]).then(() => undefined); |
|
| 286 | + | } |
|
| 287 | + | ||
| 288 | + | policyShow(root: string): Promise<PolicyInfo | null> { |
|
| 289 | + | return this.json<PolicyInfo | null>(root, ["policy", "show"]); |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | verify(root: string, change?: string): Promise<VerifyInfo[]> { |
|
| 293 | + | return this.json<VerifyInfo[]>(root, ["verify", ...(change ? [change] : [])], [0, 1]); |
|
| 294 | + | } |
|
| 295 | + | ||
| 296 | + | tags(root: string): Promise<TagInfo[]> { |
|
| 297 | + | return this.json<TagInfo[]>(root, ["tag", "list"]); |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | workspaces(root: string): Promise<WorkspaceInfo[]> { |
|
| 301 | + | return this.json<WorkspaceInfo[]>(root, ["workspace", "list"]); |
|
| 302 | + | } |
|
| 303 | + | ||
| 304 | + | workspaceAdd(root: string, path: string, changes: string[] = []): Promise<void> { |
|
| 305 | + | return this.check(root, ["workspace", "add", path, ...changes]).then(() => undefined); |
|
| 306 | + | } |
|
| 307 | + | } |
|
| 308 | + | ||
| 309 | + | export interface SubrepoInfo { |
|
| 310 | + | path: string; |
|
| 311 | + | pin: PinInfo; |
|
| 312 | + | actual: PinInfo | null; |
|
| 313 | + | at_pin: boolean; |
|
| 314 | + | dirty: boolean; |
|
| 315 | + | url: string | null; |
|
| 316 | + | } |
|
| 317 | + | ||
| 318 | + | export type PinInfo = { kind: "oxy"; repo: string; heads: string[]; bookmark: string } | { kind: "git"; sha: string; branch: string }; |
|
| 319 | + | ||
| 320 | + | export type BisectStatus = |
|
| 321 | + | | { state: "need-marks"; good: number; bad: number } |
|
| 322 | + | | { state: "testing"; heads: string[]; remaining: number; steps_left: number } |
|
| 323 | + | | { state: "found"; culprit: string } |
|
| 324 | + | | { state: "inconclusive"; candidates: string[] }; |
|
| 325 | + | ||
| 326 | + | export interface PolicyInfo { |
|
| 327 | + | hash: string; |
|
| 328 | + | serial: number; |
|
| 329 | + | expires_ms: number; |
|
| 330 | + | expired: boolean; |
|
| 331 | + | root_threshold: number; |
|
| 332 | + | roots: { key: string; name: string; email: string }[]; |
|
| 333 | + | rules: { pattern: string; keys: { key: string; name: string; email: string }[]; threshold: number; require: string; allow_conflicts: boolean }[]; |
|
| 334 | + | revoked: { key: string; name: string; email: string }[]; |
|
| 335 | + | } |
|
| 336 | + | ||
| 337 | + | export interface VerifyInfo { |
|
| 338 | + | hash: string; |
|
| 339 | + | change_id: string; |
|
| 340 | + | status: string; |
|
| 341 | + | key: string | null; |
|
| 342 | + | name: string | null; |
|
| 343 | + | revoked: boolean; |
|
| 344 | + | reason: string | null; |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | export interface TagInfo { |
|
| 348 | + | name: string; |
|
| 349 | + | state: string; |
|
| 350 | + | heads: string[]; |
|
| 351 | + | message: string; |
|
| 352 | + | signer: string | null; |
|
| 353 | + | } |
|
| 354 | + | ||
| 355 | + | export interface WorkspaceInfo { |
|
| 356 | + | id: string; |
|
| 357 | + | path: string; |
|
| 358 | + | current: boolean; |
|
| 359 | + | working_copy: string | null; |
|
| 257 | 360 | } |
|
| 258 | 361 | ||
| 259 | 362 | export interface BlameLine { |
|
| … | 115 unchanged lines |
modified src/repository.ts +36 −3
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | import * as vscode from "vscode"; |
|
| 6 | 6 | import { basename, join, relative } from "node:path"; |
|
| 7 | - | import { BookmarkInfo, Oxy, PathStatus, StatusInfo } from "./oxy"; |
|
| 7 | + | import { BookmarkInfo, Oxy, PathStatus, StatusInfo, SubrepoInfo } from "./oxy"; |
|
| 8 | 8 | import { baseUri } from "./contentProvider"; |
|
| 9 | 9 | ||
| 10 | 10 | const STATUS_LETTER: Record<PathStatus, string> = { |
|
| … | 57 unchanged lines |
||
| 68 | 68 | } |
|
| 69 | 69 | } |
|
| 70 | 70 | ||
| 71 | + | export class SubrepoState implements vscode.SourceControlResourceState { |
|
| 72 | + | readonly resourceUri: vscode.Uri; |
|
| 73 | + | readonly decorations: vscode.SourceControlResourceDecorations; |
|
| 74 | + | readonly command: vscode.Command; |
|
| 75 | + | readonly contextValue = "subrepo"; |
|
| 76 | + | ||
| 77 | + | constructor( |
|
| 78 | + | readonly repository: Repository, |
|
| 79 | + | readonly info: SubrepoInfo, |
|
| 80 | + | ) { |
|
| 81 | + | this.resourceUri = vscode.Uri.file(join(repository.root, info.path)); |
|
| 82 | + | const pin = info.pin.kind === "oxy" ? `${info.pin.bookmark} ${info.pin.heads.map((h) => h.slice(0, 8)).join(" ")}` : `git ${info.pin.branch} ${info.pin.sha.slice(0, 10)}`; |
|
| 83 | + | const state = !info.actual ? "missing (update --init)" : info.dirty ? "uncommitted changes inside" : info.at_pin ? "at pin" : "differs from pin"; |
|
| 84 | + | this.decorations = { tooltip: `${pin} · ${state}`, faded: !info.actual, strikeThrough: false }; |
|
| 85 | + | this.command = { command: "oxygen.openSubrepo", title: "Open", arguments: [this] }; |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | ||
| 71 | 89 | export class Repository implements vscode.Disposable, vscode.QuickDiffProvider { |
|
| 72 | 90 | readonly sourceControl: vscode.SourceControl; |
|
| 73 | 91 | readonly conflictsGroup: vscode.SourceControlResourceGroup; |
|
| 74 | 92 | readonly changesGroup: vscode.SourceControlResourceGroup; |
|
| 93 | + | readonly subreposGroup: vscode.SourceControlResourceGroup; |
|
| 94 | + | private subreposLatest: SubrepoInfo[] = []; |
|
| 75 | 95 | private readonly statusBar: vscode.StatusBarItem; |
|
| 76 | 96 | private readonly syncBar: vscode.StatusBarItem; |
|
| 77 | 97 | private bookmarksLatest: BookmarkInfo[] = []; |
|
| … | 18 unchanged lines |
||
| 96 | 116 | this.conflictsGroup = this.sourceControl.createResourceGroup("conflicts", "Conflicts"); |
|
| 97 | 117 | this.conflictsGroup.hideWhenEmpty = true; |
|
| 98 | 118 | this.changesGroup = this.sourceControl.createResourceGroup("changes", "Changes"); |
|
| 119 | + | this.subreposGroup = this.sourceControl.createResourceGroup("subrepos", "Subrepos"); |
|
| 120 | + | this.subreposGroup.hideWhenEmpty = true; |
|
| 99 | 121 | this.statusBar = vscode.window.createStatusBarItem("oxygen.status", vscode.StatusBarAlignment.Left, 100); |
|
| 100 | 122 | this.statusBar.name = "Oxygen"; |
|
| 101 | 123 | this.statusBar.command = { command: "oxygen.switchBookmark", title: "Switch bookmark", arguments: [this] }; |
|
| 102 | 124 | this.syncBar = vscode.window.createStatusBarItem("oxygen.sync", vscode.StatusBarAlignment.Left, 99); |
|
| 103 | 125 | this.syncBar.name = "Oxygen sync"; |
|
| 104 | 126 | this.syncBar.command = { command: "oxygen.sync", title: "Sync", arguments: [this] }; |
|
| 105 | - | this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter); |
|
| 127 | + | this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.subreposGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter); |
|
| 106 | 128 | ||
| 107 | 129 | if (vscode.workspace.getConfiguration("oxygen").get<boolean>("autoRefresh", true)) { |
|
| 108 | 130 | // Files in the workspace, except the repository's own store. |
|
| … | 24 unchanged lines |
||
| 133 | 155 | return this.bookmarksLatest; |
|
| 134 | 156 | } |
|
| 135 | 157 | ||
| 158 | + | get subrepos(): SubrepoInfo[] { |
|
| 159 | + | return this.subreposLatest; |
|
| 160 | + | } |
|
| 161 | + | ||
| 136 | 162 | get currentBookmark(): BookmarkInfo | undefined { |
|
| 137 | 163 | return this.bookmarksLatest.find((b) => b.current); |
|
| 138 | 164 | } |
|
| … | 35 unchanged lines |
||
| 174 | 200 | ||
| 175 | 201 | private async doRefresh(): Promise<void> { |
|
| 176 | 202 | try { |
|
| 177 | - | 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[])]); |
|
| 203 | + | const [status, prompt, bookmarks, subrepos] = await Promise.all([ |
|
| 204 | + | this.oxy.status(this.root), |
|
| 205 | + | this.oxy.prompt(this.root), |
|
| 206 | + | this.oxy.bookmarks(this.root).catch(() => [] as BookmarkInfo[]), |
|
| 207 | + | this.oxy.submodules(this.root).catch(() => [] as SubrepoInfo[]), |
|
| 208 | + | ]); |
|
| 178 | 209 | this.latest = status; |
|
| 179 | 210 | this.bookmarksLatest = bookmarks; |
|
| 211 | + | this.subreposLatest = subrepos; |
|
| 212 | + | this.subreposGroup.resourceStates = subrepos.map((s) => new SubrepoState(this, s)); |
|
| 180 | 213 | const conflicted = new Set(status.conflicts.map((c) => c.path).filter((p): p is string => !!p)); |
|
| 181 | 214 | const conflicts: OxyResourceState[] = []; |
|
| 182 | 215 | const changes: OxyResourceState[] = []; |
|
| … | 76 unchanged lines |
modified test/oxy.test.mjs +16 −0
| … | 122 unchanged lines |
||
| 123 | 123 | assert.match(await oxy.diffText(root, `#${blame[1].hash.slice(0, 12)}`), /\+feature/); |
|
| 124 | 124 | const history = await oxy.log(root, 50, "a.txt"); |
|
| 125 | 125 | assert.equal(history.changes.filter((c) => !c.working_copy).length, 2); |
|
| 126 | + | ||
| 127 | + | // Tags, workspaces, trust, bisect status, subrepos. |
|
| 128 | + | await oxy.tagCreate(root, "v1", [], "first"); |
|
| 129 | + | const tags = await oxy.tags(root); |
|
| 130 | + | assert.deepEqual(tags.map((t) => [t.name, t.message]), [["v1", "first"]]); |
|
| 131 | + | const workspaces = await oxy.workspaces(root); |
|
| 132 | + | assert.equal(workspaces.length, 1); |
|
| 133 | + | assert.equal(workspaces[0].current, true); |
|
| 134 | + | assert.equal(await oxy.policyShow(root), null, "no policy yet"); |
|
| 135 | + | const verify = await oxy.verify(root); |
|
| 136 | + | assert.ok(verify.every((v) => v.status === "unsigned")); |
|
| 137 | + | assert.equal(await oxy.bisectStatus(root), null); |
|
| 138 | + | assert.deepEqual(await oxy.submodules(root), []); |
|
| 139 | + | await oxy.bisectStart(root); |
|
| 140 | + | assert.equal((await oxy.bisectStatus(root))?.state, "need-marks"); |
|
| 141 | + | await oxy.bisectReset(root); |
|
| 126 | 142 | }); |
|
| 127 | 143 |