OXYGENHUB

Notifications in the status bar and an inbox (0.3.0) b2dd082c57e4

A bell shows the unread count across every signed-in hub and announces
new notifications; Open Inbox lists them, unread first, marks the one
picked as read and opens it: proposals and issues of the workspace's
repositories in the editor, anything else on the hub. Mark all as read
sits at the top. Spec §9.1.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 05:47 UTC
Signature
✔ signed by Andy McCall
Hash
b2dd082c57e48928e3989fc2b988c6c736df02606e0e0d37aca2473e84882dd5
Depends on
486c15b00314 bf73998c5198 f0b06deac4b9
Depended on by
991516a107fa 263de209e5ba 5888949525ec e04950748142 426ddc5039b5 092b8bf9f2ac 1cc0ff44e5ca f902d5b8bfeb
In bookmarks
main
In tags
v0.10.0 v0.10.1 v0.10.2 v0.11.0 v0.11.1 v0.11.2 v0.13.0
Edits
21 atoms, 1 tree operation, 9 files

Browse the tree at this change

added src/inbox.ts +128 −0
1 +
// Notifications (spec §9.1): a bell in the status bar with the unread
2 +
// count across every signed-in hub, and an inbox quick pick that marks an
3 +
// entry read and opens it: in the editor when it is a proposal or issue
4 +
// of a repository in the workspace, on the hub otherwise.
5 +
6 +
import * as vscode from "vscode";
7 +
import type * as api from "./hubapi";
8 +
import { HubAuth } from "./auth";
9 +
import { HubTarget, hubTarget } from "./hub";
10 +
11 +
/** Opens a proposal or issue in the editor; false when it is not in the workspace. */
12 +
export type Opener = (target: Extract<HubTarget, { kind: "proposal" | "issue" }>) => Promise<boolean>;
13 +
14 +
interface Entry {
15 +
  base: string;
16 +
  n: api.Notification;
17 +
}
18 +
19 +
export class InboxBar implements vscode.Disposable {
20 +
  private readonly item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 39);
21 +
  private timer: NodeJS.Timeout | undefined;
22 +
  /** Unread count last seen, to announce only what is new. */
23 +
  private lastUnread: number | undefined;
24 +
25 +
  constructor(
26 +
    private readonly auth: HubAuth,
27 +
    private readonly open: Opener,
28 +
  ) {
29 +
    this.item.command = "oxygenhub.inbox";
30 +
    this.item.name = "Oxygenhub notifications";
31 +
  }
32 +
33 +
  /** Every hub's inbox; hubs that fail (offline, token without user:read) are skipped. */
34 +
  private async fetchAll(): Promise<{ unread: number; entries: Entry[]; failed: string[] }> {
35 +
    const out = { unread: 0, entries: [] as Entry[], failed: [] as string[] };
36 +
    for (const s of this.auth.all()) {
37 +
      try {
38 +
        const inbox = await this.auth.clientFor(s.base).notifications();
39 +
        out.unread += inbox.unread;
40 +
        out.entries.push(...inbox.items.map((n) => ({ base: s.base, n })));
41 +
      } catch {
42 +
        out.failed.push(s.base);
43 +
      }
44 +
    }
45 +
    out.entries.sort((a, b) => Number(a.n.read) - Number(b.n.read) || b.n.at_ms - a.n.at_ms);
46 +
    return out;
47 +
  }
48 +
49 +
  async refresh(): Promise<void> {
50 +
    if (!this.auth.all().length) {
51 +
      this.item.hide();
52 +
      return;
53 +
    }
54 +
    const { unread, entries, failed } = await this.fetchAll();
55 +
    this.item.text = unread ? `$(bell-dot) ${unread}` : "$(bell)";
56 +
    this.item.tooltip = unread ? `${unread} unread notification${unread === 1 ? "" : "s"}` : "No unread notifications";
57 +
    if (failed.length) {
58 +
      this.item.tooltip += `\nCould not read: ${failed.join(", ")} (the token needs user:read)`;
59 +
    }
60 +
    this.item.show();
61 +
    const announce = vscode.workspace.getConfiguration("oxygenhub").get<boolean>("announceNotifications") ?? true;
62 +
    if (announce && this.lastUnread !== undefined && unread > this.lastUnread) {
63 +
      const newest = entries.find((e) => !e.n.read);
64 +
      const more = unread - this.lastUnread;
65 +
      const text = more === 1 && newest ? `Oxygenhub: ${newest.n.actor ? newest.n.actor + ": " : ""}${newest.n.title}` : `Oxygenhub: ${more} new notifications`;
66 +
      void vscode.window.showInformationMessage(text, "Open Inbox").then((pick) => pick && vscode.commands.executeCommand("oxygenhub.inbox"));
67 +
    }
68 +
    this.lastUnread = unread;
69 +
  }
70 +
71 +
  /** The inbox quick pick. */
72 +
  async show(): Promise<void> {
73 +
    const { entries } = await this.fetchAll();
74 +
    type Pick = vscode.QuickPickItem & { entry?: Entry; all?: boolean };
75 +
    const items: Pick[] = entries.map((e) => ({
76 +
      label: `${e.n.read ? "$(circle-outline)" : "$(circle-filled)"} ${e.n.title}`,
77 +
      description: [e.n.actor, e.n.kind].filter(Boolean).join(" · "),
78 +
      detail: `${new Date(e.n.at_ms).toLocaleString()} · ${new URL(e.base).host}`,
79 +
      entry: e,
80 +
    }));
81 +
    if (entries.some((e) => !e.n.read)) {
82 +
      items.unshift({ label: "$(check-all) Mark all as read", all: true, alwaysShow: true });
83 +
    }
84 +
    if (!entries.length) {
85 +
      void vscode.window.showInformationMessage("Oxygenhub: your inbox is empty.");
86 +
      return;
87 +
    }
88 +
    const pick = await vscode.window.showQuickPick(items, { placeHolder: "Oxygenhub inbox", matchOnDescription: true, matchOnDetail: true });
89 +
    if (!pick) {
90 +
      return;
91 +
    }
92 +
    if (pick.all) {
93 +
      for (const s of this.auth.all()) {
94 +
        await this.auth.clientFor(s.base).markAllNotificationsRead().catch(() => undefined);
95 +
      }
96 +
      await this.refresh();
97 +
      return;
98 +
    }
99 +
    const e = pick.entry!;
100 +
    if (!e.n.read) {
101 +
      await this.auth.clientFor(e.base).markNotificationRead(e.n.id).catch(() => undefined);
102 +
    }
103 +
    const target = hubTarget(e.n.url);
104 +
    const opened = target.kind !== "other" && (await this.open(target).catch(() => false));
105 +
    if (!opened) {
106 +
      void vscode.env.openExternal(vscode.Uri.parse(e.n.url));
107 +
    }
108 +
    await this.refresh();
109 +
  }
110 +
111 +
  start(): void {
112 +
    const seconds = vscode.workspace.getConfiguration("oxygenhub").get<number>("autoRefresh") ?? 60;
113 +
    if (this.timer) {
114 +
      clearInterval(this.timer);
115 +
    }
116 +
    if (seconds > 0) {
117 +
      this.timer = setInterval(() => void this.refresh(), seconds * 1000);
118 +
    }
119 +
    void this.refresh();
120 +
  }
121 +
122 +
  dispose(): void {
123 +
    if (this.timer) {
124 +
      clearInterval(this.timer);
125 +
    }
126 +
    this.item.dispose();
127 +
  }
128 +
}
modified CHANGELOG.md +6 −0
1 1
# Changelog
2 2
3 +
## 0.3.0
4 +
5 +
- A bell in the status bar with your unread notifications across every hub you are signed in to, and a message when new ones arrive (`oxygenhub.announceNotifications`).
6 +
- **Open Inbox**: your notifications, unread first; pick one to mark it read and open it, in the editor for proposals and issues of your workspace's repositories. Mark all as read from the top of the list.
7 +
- Needs Oxygenhub with the notifications API (2026-09-28) and a token with `user:read`.
8 +
3 9
## 0.2.0
4 10
5 11
- Proposals expand to their changed files. Click one to open it in VS Code's diff editor: the target's state against the projected state, straight from the hub, no checkout needed.
…
23 unchanged lines
modified README.md +1 −0
…
6 unchanged lines
7 7
8 8
- **Sign in** with a hub URL and a personal access token (Settings › Tokens on the hub).
9 9
- **Proposals** view: needing me, mine, open; detail with blockers, approvals, checks and changes; check out the projected state; review; seal your approval and land, both through `oxy` in a terminal with your own key.
10 +
- **Notifications**: an unread count in the status bar and an inbox that opens proposals and issues in the editor.
10 11
- **Diffs in the editor**: expand a proposal to its files and open each in VS Code's diff editor (target against projected state), or View Changes for all of them at once, without checking out.
11 12
- **Review threads** in those diffs and on the checked-out proposal's files, anchored to atoms so they follow lines through rebases.
12 13
- **Issues** view: open, create, comment, close, assign.
…
4 unchanged lines
modified docs/spec.md +4 −0
…
78 unchanged lines
79 79
80 80
A status bar item shows, for the current repository's current bookmark, the open proposal whose source it is and the state of its checks (✔ n passed, ✘ failing, … running). It subscribes to the hub's proposal event stream and refreshes on `check` events; clicking opens the proposal.
81 81
82 +
### 9.1 Notifications
83 +
84 +
A bell in the status bar shows the unread count summed over every signed-in hub (`GET /notifications`, polled on `oxygenhub.autoRefresh`). When the count rises, a message names the newest entry (or the number of new ones) with **Open Inbox**; `oxygenhub.announceNotifications` turns that off. **Open Inbox** is a quick pick of the newest entries, unread first, with **Mark all as read** at the top. Picking one marks it read and opens it in the editor when it is a proposal or issue of a repository in the workspace (the proposal detail or the issue view), otherwise on the hub. A hub whose token lacks `user:read` is skipped and named in the bell's tooltip.
85 +
82 86
## 10. Configuration
83 87
84 88
`oxygenhub.hubs`: known hub base URLs (also learnt from sign in). `oxygenhub.proposalBookmarkPrefix`: `proposal/`. `oxygenhub.autoRefresh`: seconds between view refreshes (60).
…
14 unchanged lines
modified package.json +12 −1
…
1 unchanged lines
2 2
  "name": "oxygenhub-vscode",
3 3
  "displayName": "Oxygenhub",
4 4
  "description": "Proposals, reviews, issues and checks from an Oxygenhub, in the editor. Builds on the Oxygen extension.",
5 -
  "version": "0.2.0",
5 +
  "version": "0.3.0",
6 6
  "publisher": "andymccall",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygenhub.png",
…
47 unchanged lines
56 56
          "type": "number",
57 57
          "default": 60,
58 58
          "description": "Seconds between view refreshes; 0 disables."
59 +
        },
60 +
        "oxygenhub.announceNotifications": {
61 +
          "type": "boolean",
62 +
          "default": true,
63 +
          "description": "Show a message when new notifications arrive (checked on the autoRefresh interval)."
59 64
        }
60 65
      }
61 66
    },
…
42 unchanged lines
104 109
        "icon": "$(refresh)"
105 110
      },
106 111
      {
112 +
        "command": "oxygenhub.inbox",
113 +
        "title": "Open Inbox",
114 +
        "category": "Oxygenhub",
115 +
        "icon": "$(bell)"
116 +
      },
117 +
      {
107 118
        "command": "oxygenhub.openProposal",
108 119
        "title": "Open Proposal",
109 120
        "category": "Oxygenhub"
…
222 unchanged lines
modified src/extension.ts +20 −0
…
5 unchanged lines
6 6
import { ChecksBar } from "./checks";
7 7
import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff";
8 8
import { hubUrl } from "./hub";
9 +
import { InboxBar } from "./inbox";
9 10
import { IssueNode, IssuesProvider, showIssue } from "./issues";
10 11
import type { OxygenAPI } from "./oxygenapi";
11 12
import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals";
…
7 unchanged lines
19 20
let review: ReviewController;
20 21
let bar: ChecksBar;
21 22
let content: ProposalContentProvider;
23 +
let inbox: InboxBar;
22 24
23 25
export async function activate(context: vscode.ExtensionContext): Promise<void> {
24 26
  auth = new HubAuth(context.secrets);
…
13 unchanged lines
38 40
  issues = new IssuesProvider(() => proposals.repos(), auth);
39 41
  review = new ReviewController(auth);
40 42
  bar = new ChecksBar(auth, () => proposals.repos());
43 +
  inbox = new InboxBar(auth, async (t) => {
44 +
    const repo = (await proposals.repos()).find((r) => r.base === t.base && r.owner === t.owner && r.name === t.name);
45 +
    if (!repo) {
46 +
      return false;
47 +
    }
48 +
    const client = auth.clientFor(repo.base);
49 +
    if (t.kind === "proposal") {
50 +
      const [p, checks] = await Promise.all([client.proposal(repo.owner, repo.name, t.number), client.proposalChecks(repo.owner, repo.name, t.number).catch(() => [])]);
51 +
      showDetail(repo, p, checks);
52 +
    } else {
53 +
      await showIssue(auth, repo, await client.issue(repo.owner, repo.name, t.number));
54 +
    }
55 +
    return true;
56 +
  });
57 +
  context.subscriptions.push(inbox);
41 58
  content = new ProposalContentProvider(auth);
42 59
  context.subscriptions.push(content, vscode.workspace.registerTextDocumentContentProvider(SCHEME, content));
43 60
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.proposals", proposals), vscode.window.registerTreeDataProvider("oxygenhub.issues", issues), review, bar);
…
13 unchanged lines
57 74
      }
58 75
    }),
59 76
    vscode.commands.registerCommand("oxygenhub.refresh", () => refreshAll()),
77 +
    vscode.commands.registerCommand("oxygenhub.inbox", () => inbox.show()),
60 78
    vscode.commands.registerCommand("oxygenhub.openProposal", async (node?: ProposalNode) => {
61 79
      const n = node ?? (await pickProposal());
62 80
      if (!n) {
…
60 unchanged lines
123 141
    }),
124 142
  );
125 143
  bar.start();
144 +
  inbox.start();
126 145
}
127 146
128 147
function refreshAll(): void {
…
1 unchanged lines
130 149
  refreshOpenDiffs();
131 150
  issues.refresh();
132 151
  void bar.refresh();
152 +
  void inbox.refresh();
133 153
}
134 154
135 155
/** Reload the sides of open proposal diffs, which may have gained changes. */
…
177 unchanged lines
modified src/hub.ts +24 −0
…
66 unchanged lines
67 67
    return this.request("GET", "/user");
68 68
  }
69 69
70 +
  /** The caller's inbox: unread count and newest entries (needs user:read). */
71 +
  notifications(limit = 50): Promise<api.Inbox> {
72 +
    return this.request("GET", `/notifications?limit=${limit}`);
73 +
  }
74 +
75 +
  markNotificationRead(id: number): Promise<void> {
76 +
    return this.request("POST", `/notifications/${id}/read`);
77 +
  }
78 +
79 +
  markAllNotificationsRead(): Promise<void> {
80 +
    return this.request("POST", "/notifications/read");
81 +
  }
82 +
70 83
  repositories(): Promise<api.Repository[]> {
71 84
    return this.request("GET", "/repos");
72 85
  }
…
158 unchanged lines
231 244
  }
232 245
  return { base, owner, name, number, side, path: path.replace(/^\/+/, "") };
233 246
}
247 +
248 +
/** What a hub page URL points at, for opening it in the editor instead of a browser. */
249 +
export type HubTarget = { kind: "proposal" | "issue"; base: string; owner: string; name: string; number: number } | { kind: "other"; url: string };
250 +
251 +
export function hubTarget(url: string): HubTarget {
252 +
  const m = /^(https?:\/\/[^/]+)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/(proposal|issue)\/(\d+)(?:[/?#].*)?$/.exec(url);
253 +
  if (!m) {
254 +
    return { kind: "other", url };
255 +
  }
256 +
  return { kind: m[4] as "proposal" | "issue", base: m[1], owner: m[2], name: m[3], number: Number(m[5]) };
257 +
}
modified src/hubapi.d.ts +15 −0
…
163 unchanged lines
164 164
  removed: number;
165 165
};
166 166
167 +
export type Notification = {
168 +
  actor: string;
169 +
  at_ms: number;
170 +
  id: number;
171 +
  kind: string;
172 +
  read: boolean;
173 +
  title: string;
174 +
  url: string;
175 +
};
176 +
177 +
export type Inbox = {
178 +
  items: Notification[];
179 +
  unread: number;
180 +
};
181 +
167 182
export type Review = {
168 183
  author: string;
169 184
  body: string;
…
227 unchanged lines
modified test/hub.test.mjs +14 −1
…
5 unchanged lines
6 6
import { mkdtempSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7 7
import { tmpdir } from "node:os";
8 8
import { join } from "node:path";
9 -
import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery } from "../out/hub.mjs";
9 +
import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery, hubTarget } from "../out/hub.mjs";
10 10
11 11
const HUB = join(import.meta.dirname, "../../oxygenhub/target/release/oxygenhub");
12 12
const OXY = join(import.meta.dirname, "../../oxygen/target/release/oxy");
…
63 unchanged lines
76 76
    await client.commentIssue("andy", "demo", 2, "more");
77 77
    const closed = await client.setIssueState("andy", "demo", 2, "closed");
78 78
    assert.equal(closed.state, "closed");
79 +
    // The inbox: the site token acts as the administrator, andy.
80 +
    const inbox = await client.notifications();
81 +
    assert.equal(typeof inbox.unread, "number");
82 +
    assert.ok(Array.isArray(inbox.items));
83 +
    await client.markAllNotificationsRead();
84 +
    assert.equal((await client.notifications()).unread, 0);
79 85
    const me = await client.me();
80 86
    assert.equal(me.username, "andy");
81 87
    await assert.rejects(() => new HubClient(base, "nope").me(), (e) => e instanceof HubError && e.status === 401);
…
10 unchanged lines
92 98
  assert.equal(decodeDiffQuery("hub=x&owner=a&name=b&number=3&side=left", "/p"), undefined, "side is base or head");
93 99
  assert.equal(decodeDiffQuery("owner=a&name=b&number=3&side=head", "/p"), undefined, "the hub is required");
94 100
});
101 +
102 +
test("hub page urls open in the editor when they are proposals or issues", () => {
103 +
  assert.deepEqual(hubTarget("https://earth.example:7343/andy/oxygen/proposal/12"), { kind: "proposal", base: "https://earth.example:7343", owner: "andy", name: "oxygen", number: 12 });
104 +
  assert.deepEqual(hubTarget("https://h/andy/demo/issue/3#c7"), { kind: "issue", base: "https://h", owner: "andy", name: "demo", number: 3 });
105 +
  assert.equal(hubTarget("https://h/andy/demo/releases/tag/v1").kind, "other");
106 +
  assert.equal(hubTarget("https://h/andy/demo/proposal/x").kind, "other");
107 +
});