dsh-git-badge 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -2
- package/README.md +5 -3
- package/lib/client.js +286 -14
- package/lib/index.js +234 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,12 +10,68 @@ npm. 0.6.0 is the first release recorded here.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.8.0] - 2026-09-12
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **A hover card on the input chip.** Resting a pointer on the chip now shows what
|
|
18
|
+
the chip has no room for: the file breakdown (`✎3` split into staged / unstaged
|
|
19
|
+
/ unmerged / untracked, so *ready to commit* is distinguishable from *not staged
|
|
20
|
+
yet*), the last three commits with their ages, and the stash count. When the
|
|
21
|
+
untracked walk fell back to git's collapsed count the card says `(collapsed)`
|
|
22
|
+
rather than presenting an under-count as exact.
|
|
23
|
+
The card consumes the `detail=1` payload the node half has carried unused since
|
|
24
|
+
0.6.0, and it is **lazy**: the extra fields cost a `log -3` plus a stash list, so
|
|
25
|
+
they are fetched only once a pointer rests on the chip and then kept fresh over
|
|
26
|
+
the same SSE path. A badge refresh fires on every file edit, and the everyday
|
|
27
|
+
chip still pays for none of it. It uses the shell's own seeded `Tooltip`
|
|
28
|
+
primitive — no new dependency, no seam patch — and if that primitive is ever
|
|
29
|
+
absent the chip renders uncarded rather than not at all. The boot log reports
|
|
30
|
+
which path was taken (`[dsh-git-badge] hover card = on (shell Tooltip
|
|
31
|
+
primitive).`), because the guard that keeps the badge working would otherwise
|
|
32
|
+
make a missing seed look like a missing feature.
|
|
33
|
+
- **A PR / CI token on the input chip.** When the branch has a GitHub pull
|
|
34
|
+
request the chip carries `PR#142` with the rollup state (`✓` passing, `…`
|
|
35
|
+
running, `✗` failing) and a `draft` marker. It reads the PR through the user's
|
|
36
|
+
own `gh` CLI, so the plugin never handles a credential. The rollup is reduced to
|
|
37
|
+
one worst-case state (a single failure outweighs any number of successes), the
|
|
38
|
+
token carries an accessible name naming the state, and the hover card spells out
|
|
39
|
+
the check state, draft and review decision.
|
|
40
|
+
- **The PR token links to the pull request.** When `gh` reports an http(s) URL the
|
|
41
|
+
token is an anchor that opens the PR in a new tab, so this browser tab keeps the
|
|
42
|
+
conversation. It underlines on hover or focus only — the at-rest signal is the
|
|
43
|
+
pointer cursor — and keeps the chip's own colour instead of the browser's link
|
|
44
|
+
blue, so it still reads as one row. Being an anchor, it is also keyboard-reachable
|
|
45
|
+
now, which the text token was not. The protocol is checked on both halves: only
|
|
46
|
+
`http:`/`https:` is carried or rendered, so a payload value can never become a
|
|
47
|
+
`javascript:` href.
|
|
48
|
+
- `?pr=1` on the status route. PR/CI is asked for by the **chip only**, so a
|
|
49
|
+
sidebar that surveys twenty workspaces never spawns twenty `gh` processes. The
|
|
50
|
+
read is TTL-bounded (~90s per repository), refreshed out of band exactly like
|
|
51
|
+
the existing fetch, and notifies subscribers only when the state actually
|
|
52
|
+
changes.
|
|
53
|
+
- **PR/CI degrades to an absent field, never an error.** No `gh`, a logged-out
|
|
54
|
+
`gh`, a non-GitHub remote, an offline network, a rate limit, unparseable output
|
|
55
|
+
and a timed-out call all render the unchanged chip: a caller cannot tell "no PR"
|
|
56
|
+
from "no `gh`", because neither changes what should be displayed.
|
|
57
|
+
- A `runCli` helper the git and `gh` invocations now share, so `gh` inherits the
|
|
58
|
+
same timeout / missing-binary / exit-code semantics (and `GIT_TERMINAL_PROMPT`'s
|
|
59
|
+
analogue, `GH_PROMPT_DISABLED`, keeps an auth prompt from hanging a refresh).
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- The `detail=1` payload has a consumer at last. Its annotation said "no surface
|
|
64
|
+
consumes this yet"; the input chip's hover card is that surface, so the
|
|
65
|
+
branch/last-commits/stash fields are no longer speculative.
|
|
66
|
+
- `config` gains `prStatus` (`"auto"` / `"off"`), `prTtlMs`, `prTimeoutMs` and a
|
|
67
|
+
`prRunner` test seam mirroring `gitRunner`.
|
|
68
|
+
|
|
13
69
|
## [0.7.0] - 2026-09-12
|
|
14
70
|
|
|
15
71
|
### Changed
|
|
16
72
|
|
|
17
73
|
- **Both surfaces draw the same status mark.** The input chip no longer leads with
|
|
18
|
-
a 🔴/🟡/🟢 emoji dot: it renders the SAME
|
|
74
|
+
a 🔴/🟡/🟢 emoji dot: it renders the SAME SVG mark as the sidebar row — a
|
|
19
75
|
filled **circle**, or a **tree** for a linked `git worktree` — so the shape says
|
|
20
76
|
*worktree or not* and the fill says *status*, identically wherever the badge
|
|
21
77
|
appears. The `⑂` worktree glyph is gone with the emoji: the tree shape already
|
|
@@ -136,6 +192,7 @@ npm. 0.6.0 is the first release recorded here.
|
|
|
136
192
|
- `detail=1` is annotated as having no consumer yet (the hover card remains
|
|
137
193
|
pending). Its comment previously claimed `log -1` while the code ran `log -3`.
|
|
138
194
|
|
|
139
|
-
[Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.
|
|
195
|
+
[Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.8.0...HEAD
|
|
196
|
+
[0.8.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.8.0
|
|
140
197
|
[0.7.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.7.0
|
|
141
198
|
[0.6.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.6.0
|
package/README.md
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
|
|
4
4
|
|
|
5
5
|
- **Input-row chip** — `● main ↑0 ↓2 ✎3` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
|
|
6
|
-
- **
|
|
6
|
+
- **Hover card** — rest a pointer on the chip for what won't fit: the file breakdown (`✎3` split into staged / unstaged / untracked), the last three commits, and the stash. Fetched only on hover, so the everyday badge pays nothing for it. Works on every install.
|
|
7
|
+
- **PR / CI token** — `● main ↑0 ↓2 PR#142 ✗` when the branch has a GitHub pull request, read through your own `gh` CLI (the plugin never handles a credential). The token **links to the PR** — it opens in a new tab, and underlines when you hover or focus it. Invisible when `gh` cannot answer: not installed, not logged in, no GitHub remote, offline or rate-limited all render the unchanged chip, and a token with no URL to link to stays plain text.
|
|
8
|
+
- **Sidebar row badges** — the same status mark floated to the right of each workspace row (`Project 1 ●`), with a linked worktree's name beside it (`Project 2 hotfix-tree 🌳`). Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console. The hover card and PR token are chip-only, so they never need the seam.
|
|
7
9
|
|
|
8
10
|
The leading **mark** is identical on both surfaces: a circle filled with the status colour, or a **tree** when the workspace is a linked `git worktree`. (`●`/`🌳` stand in for it here — it is a drawn SVG so that it can take the theme's status colours.)
|
|
9
11
|
|
|
10
|
-
Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample. The status API takes **no path**: the caller passes a session id (chip) or workspace id (row) and the server resolves the directory itself.
|
|
12
|
+
Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample (plus optionally one `gh` call per ~90s, chip only). The status API takes **no path**: the caller passes a session id (chip) or workspace id (row) and the server resolves the directory itself.
|
|
11
13
|
|
|
12
14
|
## Install
|
|
13
15
|
|
|
@@ -15,7 +17,7 @@ Event-driven freshness: working trees are watched and updates push over SSE —
|
|
|
15
17
|
dsh plugin --profile web add dsh-git-badge
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
Restart the web process, refresh the browser. Requires Node ≥ 20 and `git` on PATH.
|
|
20
|
+
Restart the web process, refresh the browser. Requires Node ≥ 20 and `git` on PATH. `gh`, on PATH and authenticated, is optional — it only enables the PR/CI token.
|
|
19
21
|
|
|
20
22
|
Seam details: [github.com/Kevin-McIsaac/dsh-workspace-git-badge](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) (SEAM.md / PR.md).
|
|
21
23
|
|
package/lib/client.js
CHANGED
|
@@ -10,8 +10,15 @@
|
|
|
10
10
|
* floated right, and a worktree's name beside it. Deliberately shows NO
|
|
11
11
|
* branch: the chip is the surface that names it.
|
|
12
12
|
* - conversation.input.left (upstream): chip — status mark + branch + a
|
|
13
|
-
* worktree's name + in-progress operation token + sync/dirty suffix
|
|
14
|
-
*
|
|
13
|
+
* worktree's name + in-progress operation token + sync/dirty suffix + the
|
|
14
|
+
* PR/CI token, with a HOVER CARD carrying the breakdown the chip has no room
|
|
15
|
+
* for (file detail, recent commits, stash, PR state).
|
|
16
|
+
*
|
|
17
|
+
* The hover card is the INPUT CHIP's, not the sidebar row's, and it needs
|
|
18
|
+
* nothing but the upstream slot: the Tooltip primitive is seeded by the shell
|
|
19
|
+
* itself, so the card works on a PRISTINE install with no seam patch. Its extra
|
|
20
|
+
* fields are fetched only once a pointer rests on the chip, so the everyday
|
|
21
|
+
* badge pays for none of them.
|
|
15
22
|
*/
|
|
16
23
|
window.__ModuleLoader__.load({
|
|
17
24
|
id: "dsh-git-badge",
|
|
@@ -22,6 +29,24 @@ window.__ModuleLoader__.load({
|
|
|
22
29
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
23
30
|
let react = require("react");
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Shell-seeded UI primitives. `Tooltip` is handed to EVERY client bundle
|
|
34
|
+
* by the web shell itself — it needs no `dsh.client.inject` entry and no
|
|
35
|
+
* npm dependency (dsh-client-ui-open-in-app requires it the same way).
|
|
36
|
+
*
|
|
37
|
+
* Guarded, because the seed table belongs to the shell and not to this
|
|
38
|
+
* plugin: on a shell that does not provide it the chip must still render,
|
|
39
|
+
* just without the hover card. A missing card is a smaller failure than a
|
|
40
|
+
* missing badge.
|
|
41
|
+
*/
|
|
42
|
+
let primitives = null;
|
|
43
|
+
try {
|
|
44
|
+
primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
45
|
+
} catch {
|
|
46
|
+
primitives = null;
|
|
47
|
+
}
|
|
48
|
+
const Tooltip = primitives === null ? void 0 : primitives.Tooltip;
|
|
49
|
+
|
|
25
50
|
/** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
|
|
26
51
|
const GIT_CACHE = new Map();
|
|
27
52
|
/** In-flight fetches per cacheKey: bursts collapse into one request. */
|
|
@@ -35,11 +60,21 @@ window.__ModuleLoader__.load({
|
|
|
35
60
|
* one. The chip targets its conversation (`session`); a sidebar row
|
|
36
61
|
* targets the workspace id the seam hands it. The query string doubles as
|
|
37
62
|
* the cache key.
|
|
63
|
+
*
|
|
64
|
+
* The two optional additions are the INPUT CHIP's alone, and each is an
|
|
65
|
+
* explicit request rather than something every badge pays for:
|
|
66
|
+
* - `detail=1` — recent commits and stash for the hover card;
|
|
67
|
+
* - `pr=1` — the branch's GitHub PR/CI state.
|
|
68
|
+
* A sidebar row asks for neither, so surveying N workspaces never costs N
|
|
69
|
+
* forge calls (see the node half's PR region).
|
|
38
70
|
*/
|
|
39
|
-
function targetQuery(target) {
|
|
71
|
+
function targetQuery(target, options) {
|
|
40
72
|
if (target === void 0 || target.id === void 0) return void 0;
|
|
41
73
|
const param = target.kind === "workspace" ? "workspace" : "session";
|
|
42
|
-
|
|
74
|
+
let query = param + "=" + encodeURIComponent(target.id);
|
|
75
|
+
if (options?.pr === true) query += "&pr=1";
|
|
76
|
+
if (options?.detail === true) query += "&detail=1";
|
|
77
|
+
return query;
|
|
43
78
|
}
|
|
44
79
|
|
|
45
80
|
//#region SSE change feed (one EventSource per page, ref-counted)
|
|
@@ -90,16 +125,24 @@ window.__ModuleLoader__.load({
|
|
|
90
125
|
* node half's watcher reports a change for this target (SSE), plus a slow
|
|
91
126
|
* safety-net poll in case the stream dies silently. Returns undefined
|
|
92
127
|
* while loading and for non-git workspaces.
|
|
128
|
+
*
|
|
129
|
+
* `options.detail` / `options.pr` ask the node half for the chip's extras
|
|
130
|
+
* (see targetQuery). `options.enabled: false` holds the fetch back
|
|
131
|
+
* entirely — that is what makes the hover card's extra fields LAZY: the
|
|
132
|
+
* chip renders without them and only starts paying for `log -3` plus a
|
|
133
|
+
* stash list once a pointer actually rests on it. The hook is still called
|
|
134
|
+
* unconditionally (hooks may not be conditional); the GATE is inside.
|
|
93
135
|
*/
|
|
94
|
-
function useGitStatus(target) {
|
|
95
|
-
const
|
|
136
|
+
function useGitStatus(target, options) {
|
|
137
|
+
const enabled = options?.enabled !== false;
|
|
138
|
+
const cacheKey = targetQuery(target, options);
|
|
96
139
|
const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
|
|
97
140
|
// state carries the key it belongs to: switching conversations changes
|
|
98
141
|
// the key, and showing the previous session's badge until the new fetch
|
|
99
142
|
// lands would be wrong
|
|
100
143
|
const [state, setState] = react.useState({ key: cacheKey, data: hit === void 0 ? void 0 : hit.data });
|
|
101
144
|
react.useEffect(() => {
|
|
102
|
-
if (cacheKey === void 0) return;
|
|
145
|
+
if (cacheKey === void 0 || !enabled) return;
|
|
103
146
|
let alive = true;
|
|
104
147
|
// The workspace this target resolved to, taken from the response (the
|
|
105
148
|
// server echoes it). THIS is what events are matched against: an event
|
|
@@ -149,7 +192,7 @@ window.__ModuleLoader__.load({
|
|
|
149
192
|
unsubscribe();
|
|
150
193
|
clearInterval(fallback);
|
|
151
194
|
};
|
|
152
|
-
}, [cacheKey]);
|
|
195
|
+
}, [cacheKey, enabled]);
|
|
153
196
|
return state.key === cacheKey ? state.data : void 0;
|
|
154
197
|
}
|
|
155
198
|
|
|
@@ -222,6 +265,57 @@ window.__ModuleLoader__.load({
|
|
|
222
265
|
return parts.length > 0 ? " " + parts.join(" ") : "";
|
|
223
266
|
}
|
|
224
267
|
|
|
268
|
+
/**
|
|
269
|
+
* PR / CI token — INPUT CHIP ONLY, and absent entirely when the node half
|
|
270
|
+
* has nothing to say (no `gh`, no GitHub remote, no PR for the branch), so
|
|
271
|
+
* a machine without `gh` sees precisely the chip it saw before.
|
|
272
|
+
*
|
|
273
|
+
* The state glyph is not the only channel: the token carries an accessible
|
|
274
|
+
* name spelling the state out, the same rule the status mark follows.
|
|
275
|
+
*/
|
|
276
|
+
const PR_STATE_GLYPH = { passing: "\u2713", pending: "\u2026", failing: "\u2717", none: "" };
|
|
277
|
+
const PR_STATE_LABEL = { passing: "checks passing", pending: "checks running", failing: "checks failing", none: "no checks" };
|
|
278
|
+
|
|
279
|
+
/** ` PR#142 ✗` (plus ` draft`), or "" when there is no PR to report. */
|
|
280
|
+
function formatPrToken(info) {
|
|
281
|
+
const pr = info.pr;
|
|
282
|
+
if (pr === void 0 || pr === null || pr.number === void 0) return "";
|
|
283
|
+
const state = PR_STATE_GLYPH[pr.state] === void 0 ? "none" : pr.state;
|
|
284
|
+
const glyph = PR_STATE_GLYPH[state];
|
|
285
|
+
return " PR#" + pr.number + (glyph === "" ? "" : " " + glyph) + (pr.draft === true ? " draft" : "");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The token's accessible name: "pull request 142, checks failing, draft". */
|
|
289
|
+
function prTokenLabel(info) {
|
|
290
|
+
const pr = info.pr;
|
|
291
|
+
if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
|
|
292
|
+
const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
|
|
293
|
+
return "pull request " + pr.number + ", " + state + (pr.draft === true ? ", draft" : "");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The PR's web URL, or undefined — the token is a LINK only when the node
|
|
298
|
+
* half vouched for a URL, and is plain text otherwise (no `gh`, no GitHub
|
|
299
|
+
* remote, an unauthenticated `gh`: the token still appears, it just does not
|
|
300
|
+
* pretend to be clickable, so there is no dead link to discover).
|
|
301
|
+
*
|
|
302
|
+
* The protocol is re-checked HERE even though the node half already dropped
|
|
303
|
+
* a non-http(s) value: an `href` is the one place a payload string becomes
|
|
304
|
+
* executable, so the element that creates it verifies its own input rather
|
|
305
|
+
* than trusting an upstream guard to stay in place. The shell's own markdown
|
|
306
|
+
* renderer guards its links the same way.
|
|
307
|
+
*/
|
|
308
|
+
function prLinkUrl(info) {
|
|
309
|
+
const pr = info.pr;
|
|
310
|
+
if (pr === void 0 || pr === null || typeof pr.url !== "string") return void 0;
|
|
311
|
+
try {
|
|
312
|
+
const { protocol } = new URL(pr.url);
|
|
313
|
+
return protocol === "http:" || protocol === "https:" ? pr.url : void 0;
|
|
314
|
+
} catch {
|
|
315
|
+
return void 0;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
225
319
|
/**
|
|
226
320
|
* In-progress operation token — INPUT CHIP ONLY (the row shows the status
|
|
227
321
|
* tree alone). A paused rebase or cherry-pick whose conflicts are all
|
|
@@ -293,7 +387,7 @@ window.__ModuleLoader__.load({
|
|
|
293
387
|
};
|
|
294
388
|
|
|
295
389
|
/**
|
|
296
|
-
* Status mark, SHARED BY BOTH SURFACES: the same
|
|
390
|
+
* Status mark, SHARED BY BOTH SURFACES: the same shape in the same fill
|
|
297
391
|
* wherever it appears, so the sidebar row and the input chip read
|
|
298
392
|
* identically. The FILL is the status colour; the SHAPE carries
|
|
299
393
|
* worktree-ness — a circle for a main checkout, a tree for a linked
|
|
@@ -390,6 +484,102 @@ window.__ModuleLoader__.load({
|
|
|
390
484
|
return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
|
|
391
485
|
}
|
|
392
486
|
|
|
487
|
+
/**
|
|
488
|
+
* The chip's `✎n` is one number for four different things; the card splits
|
|
489
|
+
* it, so "ready to commit" is distinguishable from "not staged yet". The
|
|
490
|
+
* collapsed caveat is the node half's own honesty flag: when the untracked
|
|
491
|
+
* walk blew its budget the count is a DIRECTORY count, and saying so beats
|
|
492
|
+
* presenting an under-count as exact.
|
|
493
|
+
*/
|
|
494
|
+
function formatFileBreakdown(info) {
|
|
495
|
+
const parts = [];
|
|
496
|
+
if ((info.stagedFiles || 0) > 0) parts.push(info.stagedFiles + " staged");
|
|
497
|
+
if ((info.unstagedFiles || 0) > 0) parts.push(info.unstagedFiles + " unstaged");
|
|
498
|
+
if ((info.unmergedFiles || 0) > 0) parts.push(info.unmergedFiles + " unmerged");
|
|
499
|
+
const untracked = info.untrackedFiles || 0;
|
|
500
|
+
if (untracked > 0) parts.push(untracked + " untracked" + (info.untrackedMode === "collapsed" ? " (collapsed)" : ""));
|
|
501
|
+
return parts.length === 0 ? "clean" : parts.join(" \u00B7 ");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The PR's full state as text, for the card: "#142 · checks failing · draft · review approved". */
|
|
505
|
+
function formatPrDetail(pr) {
|
|
506
|
+
if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
|
|
507
|
+
const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
|
|
508
|
+
const bits = ["#" + pr.number, state];
|
|
509
|
+
if (pr.draft === true) bits.push("draft");
|
|
510
|
+
if (pr.review !== void 0) bits.push(String(pr.review).toLowerCase().replace(/_/g, " "));
|
|
511
|
+
if (pr.open === false) bits.push("not open");
|
|
512
|
+
return bits.join(" \u00B7 ");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const CARD_CONTAINER = {
|
|
516
|
+
display: "flex",
|
|
517
|
+
flexDirection: "column",
|
|
518
|
+
gap: "4px",
|
|
519
|
+
fontSize: "12px",
|
|
520
|
+
lineHeight: "18px",
|
|
521
|
+
maxWidth: "340px"
|
|
522
|
+
};
|
|
523
|
+
const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
|
|
524
|
+
const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
|
|
525
|
+
const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
|
|
529
|
+
* over fields the status response already carries, plus the `detail=1`
|
|
530
|
+
* additions (recent commits, stash) when they have arrived. Everything is
|
|
531
|
+
* spelled out in text, so no fact here depends on a colour or a glyph.
|
|
532
|
+
*/
|
|
533
|
+
function HoverCard({ info, detail }) {
|
|
534
|
+
// the detail payload is a superset of the plain one, so it wins when it
|
|
535
|
+
// has landed; until then the card still shows the base facts
|
|
536
|
+
const data = detail === void 0 ? info : detail;
|
|
537
|
+
const rows = [];
|
|
538
|
+
const add = (label, value) => {
|
|
539
|
+
if (value === void 0 || value === null || value === "") return;
|
|
540
|
+
rows.push(
|
|
541
|
+
react_jsx_runtime.jsxs("div", {
|
|
542
|
+
style: CARD_ROW,
|
|
543
|
+
children: [
|
|
544
|
+
react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
|
|
545
|
+
react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
|
|
546
|
+
]
|
|
547
|
+
}, label)
|
|
548
|
+
);
|
|
549
|
+
};
|
|
550
|
+
add("branch", info.branch);
|
|
551
|
+
add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
|
|
552
|
+
if (info.ahead !== void 0 || info.behind !== void 0) {
|
|
553
|
+
add("sync", "\u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0));
|
|
554
|
+
}
|
|
555
|
+
add("files", formatFileBreakdown(info));
|
|
556
|
+
add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
|
|
557
|
+
add("pull request", formatPrDetail(data.pr));
|
|
558
|
+
if (Array.isArray(data.lastCommits) && data.lastCommits.length > 0) {
|
|
559
|
+
rows.push(
|
|
560
|
+
react_jsx_runtime.jsxs("div", {
|
|
561
|
+
style: CARD_ROW,
|
|
562
|
+
children: [
|
|
563
|
+
react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: "commits" }),
|
|
564
|
+
react_jsx_runtime.jsx("span", {
|
|
565
|
+
style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
|
|
566
|
+
children: data.lastCommits.map((commit) =>
|
|
567
|
+
react_jsx_runtime.jsxs("span", {
|
|
568
|
+
children: [
|
|
569
|
+
react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: commit.hash }),
|
|
570
|
+
" " + commit.subject + (commit.when === "" ? "" : " \u00B7 " + commit.when)
|
|
571
|
+
]
|
|
572
|
+
}, commit.hash)
|
|
573
|
+
)
|
|
574
|
+
})
|
|
575
|
+
]
|
|
576
|
+
}, "commits")
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
|
|
580
|
+
return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
|
|
581
|
+
}
|
|
582
|
+
|
|
393
583
|
//#region composer chip (upstream additive surface: conversation.input.left)
|
|
394
584
|
/**
|
|
395
585
|
* Chip line in the input row: git state of the workspace the CURRENT
|
|
@@ -399,17 +589,32 @@ window.__ModuleLoader__.load({
|
|
|
399
589
|
* (conversation.input.left is an upstream additive slot), and stays useful
|
|
400
590
|
* next to the sidebar rows because it is context-anchored ("where am I")
|
|
401
591
|
* rather than surveying.
|
|
592
|
+
*
|
|
593
|
+
* This is the ONLY surface that asks the node half for the extras (`pr=1`
|
|
594
|
+
* always, `detail=1` on hover), because it is the only one that renders
|
|
595
|
+
* them — see targetQuery.
|
|
402
596
|
*/
|
|
403
597
|
function ComposerGitChip({ sessionId }) {
|
|
404
|
-
|
|
405
|
-
//
|
|
406
|
-
const info = useGitStatus(
|
|
598
|
+
const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
|
|
599
|
+
// the PR/CI token is always on the chip, so its fetch is not gated
|
|
600
|
+
const info = useGitStatus(target, { pr: true });
|
|
601
|
+
// The card's extras cost a `log -3` plus a stash list, so they are
|
|
602
|
+
// fetched only once a pointer actually RESTS on the chip, then kept
|
|
603
|
+
// fresh by the same SSE path. Eagerly asking would add both invocations
|
|
604
|
+
// to every refresh — and a refresh fires on every file edit.
|
|
605
|
+
const [hovered, setHovered] = react.useState(false);
|
|
606
|
+
// the PR token's own hover/focus state: it underlines on hover (and on
|
|
607
|
+
// focus) rather than at rest, and that underline is applied inline below
|
|
608
|
+
const [linkHover, setLinkHover] = react.useState(false);
|
|
609
|
+
const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
|
|
407
610
|
if (info === void 0 || info.git !== true) return null;
|
|
408
611
|
// the mark is an element now rather than a leading glyph in the string, so
|
|
409
612
|
// the SAME StatusMark the sidebar row draws carries the status here too;
|
|
410
613
|
// the container's 4px gap supplies the space the emoji's own did
|
|
411
614
|
const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
|
|
412
|
-
|
|
615
|
+
const prToken = formatPrToken(info);
|
|
616
|
+
const prUrl = prLinkUrl(info);
|
|
617
|
+
const chip = react_jsx_runtime.jsxs("span", {
|
|
413
618
|
style: {
|
|
414
619
|
display: "inline-flex",
|
|
415
620
|
alignItems: "center",
|
|
@@ -421,11 +626,63 @@ window.__ModuleLoader__.load({
|
|
|
421
626
|
whiteSpace: "nowrap",
|
|
422
627
|
cursor: "default"
|
|
423
628
|
},
|
|
629
|
+
// the card is fetched for a pointer that RESTS here, not one that
|
|
630
|
+
// merely crosses the chip
|
|
631
|
+
onPointerEnter: () => setHovered(true),
|
|
424
632
|
children: [
|
|
425
633
|
react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
|
|
426
|
-
react_jsx_runtime.jsx("span", { key: "text", children: text })
|
|
634
|
+
react_jsx_runtime.jsx("span", { key: "text", children: text }),
|
|
635
|
+
prToken === ""
|
|
636
|
+
? null
|
|
637
|
+
: prUrl === void 0
|
|
638
|
+
? react_jsx_runtime.jsx("span", {
|
|
639
|
+
key: "pr",
|
|
640
|
+
// the glyph is not the only channel: the token says what the
|
|
641
|
+
// CI state IS, for anyone who cannot see it
|
|
642
|
+
"aria-label": prTokenLabel(info),
|
|
643
|
+
children: prToken
|
|
644
|
+
})
|
|
645
|
+
: react_jsx_runtime.jsx("a", {
|
|
646
|
+
key: "pr",
|
|
647
|
+
href: prUrl,
|
|
648
|
+
// a new tab, because navigating THIS one away from the app
|
|
649
|
+
// would lose the conversation; noopener/noreferrer keep the
|
|
650
|
+
// opened tab from getting a handle on it
|
|
651
|
+
target: "_blank",
|
|
652
|
+
rel: "noopener noreferrer",
|
|
653
|
+
"aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
|
|
654
|
+
// the chip's own colour rather than the browser's link blue /
|
|
655
|
+
// visited purple, so the token still reads as one row. The
|
|
656
|
+
// affordance is the pointer cursor plus an underline on hover
|
|
657
|
+
// or focus, set HERE rather than left to the host stylesheet:
|
|
658
|
+
// a shell that resets anchors would otherwise drop it silently.
|
|
659
|
+
style: {
|
|
660
|
+
color: "inherit",
|
|
661
|
+
textDecoration: linkHover ? "underline" : "none",
|
|
662
|
+
cursor: "pointer"
|
|
663
|
+
},
|
|
664
|
+
// focus joins hover — the token is keyboard-reachable now, and a
|
|
665
|
+
// keyboard user needs the same "this is a link" signal
|
|
666
|
+
onPointerEnter: () => setLinkHover(true),
|
|
667
|
+
onPointerLeave: () => setLinkHover(false),
|
|
668
|
+
onFocus: () => setLinkHover(true),
|
|
669
|
+
onBlur: () => setLinkHover(false),
|
|
670
|
+
children: prToken
|
|
671
|
+
})
|
|
427
672
|
]
|
|
428
673
|
});
|
|
674
|
+
// No Tooltip primitive (a shell that does not seed it): render the chip
|
|
675
|
+
// itself rather than losing the badge. The card is an enhancement, the
|
|
676
|
+
// badge is the feature.
|
|
677
|
+
if (Tooltip === void 0) return chip;
|
|
678
|
+
return react_jsx_runtime.jsx(Tooltip, {
|
|
679
|
+
side: "top",
|
|
680
|
+
maxWidth: 360,
|
|
681
|
+
// a function label keeps the card's element tree out of every render
|
|
682
|
+
// until the tooltip actually opens
|
|
683
|
+
label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
|
|
684
|
+
children: chip
|
|
685
|
+
});
|
|
429
686
|
}
|
|
430
687
|
//#endregion
|
|
431
688
|
|
|
@@ -474,10 +731,25 @@ window.__ModuleLoader__.load({
|
|
|
474
731
|
}, ComposerGitChip));
|
|
475
732
|
const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
|
|
476
733
|
console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)." : "awaiting the sidebar.workspaces.row seam — the line above reports it if it appears."));
|
|
734
|
+
// The hover card depends on a primitive the SHELL seeds, not on anything
|
|
735
|
+
// this plugin declares. Report the outcome rather than letting a missing
|
|
736
|
+
// seed look like a missing feature: the try/catch above deliberately keeps
|
|
737
|
+
// the badge working, which would otherwise make this failure silent.
|
|
738
|
+
console.info(
|
|
739
|
+
"[dsh-git-badge] hover card = " +
|
|
740
|
+
(Tooltip === void 0
|
|
741
|
+
? "off (this shell seeds no Tooltip primitive) — the chip renders uncarded."
|
|
742
|
+
: "on (shell Tooltip primitive).")
|
|
743
|
+
);
|
|
477
744
|
}
|
|
478
745
|
|
|
479
746
|
exports.apply = apply;
|
|
480
747
|
exports.inject = inject;
|
|
748
|
+
// ---------- test-only exports ----------
|
|
749
|
+
// Additive; the host reads apply/inject and ignores the rest. The suite
|
|
750
|
+
// drives these to assert the REQUEST contract — which surface asks for the
|
|
751
|
+
// expensive extras — without a browser, a fetch or a network.
|
|
752
|
+
exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail };
|
|
481
753
|
return module.exports;
|
|
482
754
|
}
|
|
483
755
|
});
|
package/lib/index.js
CHANGED
|
@@ -44,9 +44,14 @@
|
|
|
44
44
|
* on the first refresh after each TTL window. `GIT_TERMINAL_PROMPT=0`, the
|
|
45
45
|
* shared timeout and the TTL bound keep an offline or slow remote harmless;
|
|
46
46
|
* on failure the stale-ref answer simply stands.
|
|
47
|
-
* - `detail=1` adds one `log -3` plus a stash count
|
|
48
|
-
*
|
|
49
|
-
* the
|
|
47
|
+
* - `detail=1` adds one `log -3` plus a stash count, and `pr=1` adds the
|
|
48
|
+
* branch's GitHub PR/CI state read through the user's own `gh` CLI. Both
|
|
49
|
+
* serve the input chip only — the hover card and the PR token — so a
|
|
50
|
+
* sidebar row refresh pays for neither. The PR read is TTL-bounded and
|
|
51
|
+
* out of band, like the fetch; it degrades to an ABSENT field rather than
|
|
52
|
+
* an error whenever `gh` cannot answer (see the PR region).
|
|
53
|
+
* - `pr=1` requires `gh` on PATH and authenticated for a token to appear.
|
|
54
|
+
* The plugin never handles the credential: `gh` owns it.
|
|
50
55
|
*
|
|
51
56
|
* GET /api/git-badge/events (text/event-stream)
|
|
52
57
|
* - pushes `{ path }` dirty notifications the moment a workspace's git
|
|
@@ -100,23 +105,48 @@ const config = {
|
|
|
100
105
|
* collapsed-fallback branch deterministically — a real sub-millisecond budget
|
|
101
106
|
* would just race git's own startup.
|
|
102
107
|
*/
|
|
103
|
-
gitRunner: null
|
|
108
|
+
gitRunner: null,
|
|
109
|
+
/**
|
|
110
|
+
* PR / CI status via the `gh` CLI: "auto" reads it where `gh` can answer and
|
|
111
|
+
* silently shows nothing where it cannot; "off" never invokes `gh` at all.
|
|
112
|
+
* Only the input chip asks for it, so a sidebar-only view spawns no forge
|
|
113
|
+
* process — see the PR region.
|
|
114
|
+
*/
|
|
115
|
+
prStatus: "auto",
|
|
116
|
+
/** minimum interval between forge refreshes per repository */
|
|
117
|
+
prTtlMs: 90000,
|
|
118
|
+
/**
|
|
119
|
+
* Forge call budget. Unlike git this is a network round trip to GitHub, so
|
|
120
|
+
* it gets a more generous budget than the status sample — but it is bounded
|
|
121
|
+
* all the same, because nothing here may hang the route.
|
|
122
|
+
*/
|
|
123
|
+
prTimeoutMs: 6000,
|
|
124
|
+
/**
|
|
125
|
+
* Invoker for the `gh` CLI; null means the real runCli. The suite substitutes
|
|
126
|
+
* one so every degradation path (no gh, logged out, no PR, garbage output,
|
|
127
|
+
* timeout) is covered with no gh, no network and no forge.
|
|
128
|
+
*/
|
|
129
|
+
prRunner: null
|
|
104
130
|
};
|
|
105
131
|
|
|
106
132
|
/**
|
|
107
|
-
* Run
|
|
108
|
-
* plus exactly one of: timeout (execFile timeout kill),
|
|
109
|
-
* binary), or exitCode (
|
|
110
|
-
*
|
|
111
|
-
*
|
|
133
|
+
* Run an external command in a directory. Resolves { stdout } on success; on
|
|
134
|
+
* failure { stdout: null } plus exactly one of: timeout (execFile timeout kill),
|
|
135
|
+
* missing (no such binary), or exitCode (the command ran and rejected — e.g.
|
|
136
|
+
* "not a repository", or `gh` reporting no pull request).
|
|
137
|
+
*
|
|
138
|
+
* The exitCode/timeout/missing trichotomy is what lets a CALLER tell a
|
|
139
|
+
* definitive answer from a transient one: a missing `gh` and a `gh` that says
|
|
140
|
+
* "no PR" are different facts, and only the caller knows which of them it can
|
|
141
|
+
* act on. Shared by git and `gh` so neither grows its own timeout semantics.
|
|
112
142
|
*/
|
|
113
|
-
function
|
|
114
|
-
const { env, timeout = config.gitTimeoutMs } = opts ?? {};
|
|
143
|
+
function runCli(cmd, args, opts) {
|
|
144
|
+
const { cwd, env, timeout = config.gitTimeoutMs } = opts ?? {};
|
|
115
145
|
// env is a full replacement for execFile; overlay onto process.env so
|
|
116
146
|
// PATH/HOME (credential helpers, global config) survive
|
|
117
147
|
const fullEnv = env === void 0 ? void 0 : { ...process.env, ...env };
|
|
118
148
|
return new Promise((resolve) => {
|
|
119
|
-
execFile(
|
|
149
|
+
execFile(cmd, args, { cwd, timeout, env: fullEnv }, (error, stdout) => {
|
|
120
150
|
if (error === void 0 || error === null) resolve({ stdout: String(stdout) });
|
|
121
151
|
else if (error.killed) resolve({ stdout: null, timeout: true });
|
|
122
152
|
else if (error.code === "ENOENT") resolve({ stdout: null, missing: true });
|
|
@@ -125,6 +155,15 @@ function runGit(dir, args, opts) {
|
|
|
125
155
|
});
|
|
126
156
|
}
|
|
127
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Run git in dir. A thin specialization of runCli: the contract above is
|
|
160
|
+
* unchanged, and `config.gitRunner` still substitutes for the whole call so the
|
|
161
|
+
* suite can force the timeout branch.
|
|
162
|
+
*/
|
|
163
|
+
function runGit(dir, args, opts) {
|
|
164
|
+
return runCli("git", args, { ...opts, cwd: dir });
|
|
165
|
+
}
|
|
166
|
+
|
|
128
167
|
/** Marker response for a git invocation that failed or timed out (transient). */
|
|
129
168
|
const GIT_DEGRADED = { git: false, error: "git unavailable (timeout or failure)" };
|
|
130
169
|
|
|
@@ -178,6 +217,167 @@ async function maybeFetch(toplevel) {
|
|
|
178
217
|
}
|
|
179
218
|
//#endregion
|
|
180
219
|
|
|
220
|
+
//#region PR / CI status (gh)
|
|
221
|
+
/**
|
|
222
|
+
* GitHub pull-request and check state for the current branch, read through the
|
|
223
|
+
* user's own `gh` CLI.
|
|
224
|
+
*
|
|
225
|
+
* Why the CLI and not the REST API: `gh` already owns the user's credentials in
|
|
226
|
+
* its own config, so this plugin never reads, stores, caches or forwards a
|
|
227
|
+
* token. There is no secret here to leak and no OAuth flow to maintain — and a
|
|
228
|
+
* machine that has never authenticated `gh` simply gets no PR token.
|
|
229
|
+
*
|
|
230
|
+
* Degradation is a MISSING FIELD, never an error. A machine without `gh`, a
|
|
231
|
+
* logged-out `gh`, a remote that is not GitHub, an offline network, a forge rate
|
|
232
|
+
* limit, unparseable output and a timed-out call all produce exactly the same
|
|
233
|
+
* response: no `pr` key at all. That is the honest answer, because in every one
|
|
234
|
+
* of those cases the badge has nothing to say — and a caller that could tell
|
|
235
|
+
* "no PR" from "no gh" would still not know what to draw.
|
|
236
|
+
*
|
|
237
|
+
* Like the fetch above this is TTL-bounded and NEVER awaited by the route: the
|
|
238
|
+
* cached value is served immediately and a refresh that CHANGES it notifies
|
|
239
|
+
* subscribers, so the request that lapses the window carries the previous answer
|
|
240
|
+
* and the follow-up carries the new one. A forge round trip must never become
|
|
241
|
+
* badge latency.
|
|
242
|
+
*/
|
|
243
|
+
/** toplevel -> { lastAttemptAt, inFlight, value }; `value` undefined = nothing to say. */
|
|
244
|
+
const prState = new Map();
|
|
245
|
+
|
|
246
|
+
/** Test seam: the CLI invoker `gh` calls go through, so no test needs `gh`. */
|
|
247
|
+
function runPrCli(cmd, args, opts) {
|
|
248
|
+
return (config.prRunner ?? runCli)(cmd, args, opts);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Does `origin` point at github.com? A cheap LOCAL call, so a non-GitHub repo
|
|
253
|
+
* never pays for a `gh` process. Deliberately scoped to origin: that is the
|
|
254
|
+
* remote `gh` itself resolves the repository from, so agreeing with it here is
|
|
255
|
+
* what keeps the two from disagreeing later.
|
|
256
|
+
*/
|
|
257
|
+
async function originIsGitHub(toplevel) {
|
|
258
|
+
const out = await runGit(toplevel, ["remote", "get-url", "origin"]);
|
|
259
|
+
if (out.stdout === null) return false;
|
|
260
|
+
return /(^|[/@.])github\.com[:/]/i.test(out.stdout.trim());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Reduce a `statusCheckRollup` to ONE worst-case state, so the chip needs a
|
|
265
|
+
* single colour rather than a list.
|
|
266
|
+
*
|
|
267
|
+
* Two entry shapes share the array: a CheckRun carries `status` + `conclusion`,
|
|
268
|
+
* a StatusContext carries `state` alone. A single failure outweighs any number
|
|
269
|
+
* of successes and pending checks — "mostly passing" is not a thing a red/green
|
|
270
|
+
* token can express — so failure returns immediately and pending only wins over
|
|
271
|
+
* an otherwise all-successful set.
|
|
272
|
+
*/
|
|
273
|
+
function summarizeChecks(checks) {
|
|
274
|
+
let sawAny = false;
|
|
275
|
+
let sawPending = false;
|
|
276
|
+
for (const check of checks) {
|
|
277
|
+
const state = String(check?.state ?? "").toUpperCase();
|
|
278
|
+
const status = String(check?.status ?? "").toUpperCase();
|
|
279
|
+
const conclusion = String(check?.conclusion ?? "").toUpperCase();
|
|
280
|
+
sawAny = true;
|
|
281
|
+
const failed =
|
|
282
|
+
["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(state) ||
|
|
283
|
+
["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion);
|
|
284
|
+
if (failed) return "failing";
|
|
285
|
+
// unfinished: a CheckRun that has not completed, or a StatusContext PENDING
|
|
286
|
+
if (state === "PENDING" || (status !== "" && status !== "COMPLETED")) sawPending = true;
|
|
287
|
+
}
|
|
288
|
+
if (!sawAny) return "none";
|
|
289
|
+
return sawPending ? "pending" : "passing";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The compact PR shape the chip renders, or undefined when there is nothing to say. */
|
|
293
|
+
function summarizePr(json) {
|
|
294
|
+
if (json === null || typeof json !== "object") return void 0;
|
|
295
|
+
const number = Number.isFinite(json?.number) ? json.number : void 0;
|
|
296
|
+
if (number === void 0) return void 0;
|
|
297
|
+
const pr = {
|
|
298
|
+
number,
|
|
299
|
+
state: summarizeChecks(Array.isArray(json.statusCheckRollup) ? json.statusCheckRollup : []),
|
|
300
|
+
// draft and review state are separate from CI: a draft with green checks
|
|
301
|
+
// is not ready, and a blocked review is not a failing build
|
|
302
|
+
draft: json.isDraft === true,
|
|
303
|
+
open: String(json.state ?? "OPEN").toUpperCase() === "OPEN"
|
|
304
|
+
};
|
|
305
|
+
// omitted, not present-and-undefined: absence is the contract for "nothing to
|
|
306
|
+
// say" throughout this half, so a consumer tests the key, never the value
|
|
307
|
+
if (typeof json.reviewDecision === "string" && json.reviewDecision !== "") pr.review = json.reviewDecision;
|
|
308
|
+
// The chip links the token to the PR, so `url` is the one field that becomes an
|
|
309
|
+
// `href`: only http(s) is emitted, and anything else is omitted like every
|
|
310
|
+
// other "nothing to say" field, so a payload value cannot reach an anchor as a
|
|
311
|
+
// `javascript:` URL. The client re-checks before it builds the href — this half
|
|
312
|
+
// is the authority on what it vouches for, not the only guard.
|
|
313
|
+
if (typeof json.url === "string" && json.url !== "") {
|
|
314
|
+
try {
|
|
315
|
+
const { protocol } = new URL(json.url);
|
|
316
|
+
if (protocol === "http:" || protocol === "https:") pr.url = json.url;
|
|
317
|
+
} catch {
|
|
318
|
+
// not a URL at all — omitted, exactly like an absent one
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return pr;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read PR state for `branch`, or undefined. NEVER throws and never rejects: a
|
|
326
|
+
* forge is an optional luxury, so every failure path is "nothing to say".
|
|
327
|
+
*/
|
|
328
|
+
async function readPrStatus(toplevel, branch) {
|
|
329
|
+
// a detached HEAD is not a branch `gh` can resolve a PR for
|
|
330
|
+
if (typeof branch !== "string" || branch === "" || branch.startsWith("HEAD")) return void 0;
|
|
331
|
+
if (!(await originIsGitHub(toplevel))) return void 0;
|
|
332
|
+
const out = await runPrCli("gh", ["pr", "view", branch, "--json", "number,state,isDraft,reviewDecision,statusCheckRollup,url"], {
|
|
333
|
+
cwd: toplevel,
|
|
334
|
+
timeout: config.prTimeoutMs,
|
|
335
|
+
// GH_PROMPT_DISABLED: an auth prompt must never hang the refresh (the gh
|
|
336
|
+
// analogue of the git fetch's GIT_TERMINAL_PROMPT=0). The pager vars keep
|
|
337
|
+
// gh from waiting on a pager that will never answer, and NO_COLOR keeps
|
|
338
|
+
// escape codes out of the JSON.
|
|
339
|
+
env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" }
|
|
340
|
+
});
|
|
341
|
+
if (out.stdout === null) return void 0;
|
|
342
|
+
let json;
|
|
343
|
+
try {
|
|
344
|
+
json = JSON.parse(out.stdout);
|
|
345
|
+
} catch {
|
|
346
|
+
return void 0;
|
|
347
|
+
}
|
|
348
|
+
return summarizePr(json);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* PR state for toplevel: the value to serve NOW (possibly undefined, possibly
|
|
353
|
+
* stale), spawning an out-of-band refresh when the TTL has lapsed. `notify` is
|
|
354
|
+
* called only when the refreshed value CHANGES, so an unchanged answer costs one
|
|
355
|
+
* forge call per TTL and zero SSE traffic.
|
|
356
|
+
*/
|
|
357
|
+
function prStatusFor(toplevel, branch, notify) {
|
|
358
|
+
if (config.prStatus === "off") return void 0;
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
const state = prState.get(toplevel);
|
|
361
|
+
if (state !== void 0) {
|
|
362
|
+
// Fresh, or a refresh is already running: serve what we have either way.
|
|
363
|
+
if (now - state.lastAttemptAt < config.prTtlMs || state.inFlight !== null) return state.value;
|
|
364
|
+
}
|
|
365
|
+
const record = { lastAttemptAt: now, inFlight: null, value: state?.value };
|
|
366
|
+
const inFlight = (async () => {
|
|
367
|
+
const value = await readPrStatus(toplevel, branch).catch(() => void 0);
|
|
368
|
+
const changed = JSON.stringify(record.value ?? null) !== JSON.stringify(value ?? null);
|
|
369
|
+
record.lastAttemptAt = Date.now();
|
|
370
|
+
record.inFlight = null;
|
|
371
|
+
record.value = value;
|
|
372
|
+
if (changed) notify(toplevel);
|
|
373
|
+
return value;
|
|
374
|
+
})();
|
|
375
|
+
record.inFlight = inFlight;
|
|
376
|
+
prState.set(toplevel, record);
|
|
377
|
+
return record.value;
|
|
378
|
+
}
|
|
379
|
+
//#endregion
|
|
380
|
+
|
|
181
381
|
/**
|
|
182
382
|
* Parse `git status --porcelain=v2 --branch` output.
|
|
183
383
|
* Headers: `# branch.head <name>` (or `(detached)`), `# branch.upstream
|
|
@@ -259,11 +459,12 @@ function operationMarker(gitDir) {
|
|
|
259
459
|
* worktree reports that worktree (the toplevel walk starts above it), which is
|
|
260
460
|
* the same whole-repository rule as above.
|
|
261
461
|
*
|
|
262
|
-
* `wantDetail`
|
|
263
|
-
*
|
|
264
|
-
*
|
|
462
|
+
* `wantDetail` serves the input chip's HOVER CARD (recent commits + stash) and
|
|
463
|
+
* `wantPr` its PR/CI token. They are asked for separately and by the chip only,
|
|
464
|
+
* so neither is paid for by a sidebar row refresh: a row needs status and
|
|
465
|
+
* identity, nothing more.
|
|
265
466
|
*/
|
|
266
|
-
async function gitStatus(dir, wantDetail) {
|
|
467
|
+
async function gitStatus(dir, wantDetail, wantPr) {
|
|
267
468
|
// one invocation answers both questions: the repository root (so a
|
|
268
469
|
// subdirectory workspace reports the whole repo) and the per-worktree git
|
|
269
470
|
// dir (where the operation markers above live)
|
|
@@ -360,6 +561,15 @@ async function gitStatus(dir, wantDetail) {
|
|
|
360
561
|
if (count > 0) info.stashCount = count;
|
|
361
562
|
}
|
|
362
563
|
}
|
|
564
|
+
// PR / CI is the INPUT CHIP's business, so it is asked for explicitly
|
|
565
|
+
// (`?pr=1`) rather than paid for by every sidebar row: a row that surveys
|
|
566
|
+
// twenty workspaces must not spawn twenty forge processes. Served from the
|
|
567
|
+
// TTL cache and refreshed out of band, exactly like the fetch above — the
|
|
568
|
+
// forge round trip is never route latency.
|
|
569
|
+
if (wantPr) {
|
|
570
|
+
const pr = prStatusFor(toplevel, parsed.branch, notifyChange);
|
|
571
|
+
if (pr !== void 0) info.pr = pr;
|
|
572
|
+
}
|
|
363
573
|
return info;
|
|
364
574
|
}
|
|
365
575
|
|
|
@@ -675,7 +885,8 @@ function apply(ctx) {
|
|
|
675
885
|
return;
|
|
676
886
|
}
|
|
677
887
|
const detail = url.searchParams.get("detail") === "1";
|
|
678
|
-
const
|
|
888
|
+
const pr = url.searchParams.get("pr") === "1";
|
|
889
|
+
const info = await gitStatus(target.path, detail, pr);
|
|
679
890
|
// Echo the resolved workspace id. SSE events identify the workspace,
|
|
680
891
|
// not the session, so a session-targeted client has no other way to
|
|
681
892
|
// tell whether an event belongs to it — without this the input chip
|
|
@@ -757,9 +968,15 @@ export {
|
|
|
757
968
|
operationMarker,
|
|
758
969
|
outerGitDir,
|
|
759
970
|
parseStatusV2,
|
|
971
|
+
runCli,
|
|
760
972
|
runGit,
|
|
761
973
|
gitStatus,
|
|
762
974
|
resolveWorkspace,
|
|
975
|
+
readPrStatus,
|
|
976
|
+
summarizePr,
|
|
977
|
+
summarizeChecks,
|
|
978
|
+
prStatusFor,
|
|
979
|
+
prState,
|
|
763
980
|
watchWorkspace,
|
|
764
981
|
unwatchWorkspace,
|
|
765
982
|
syncWatchers,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-git-badge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Git status badges for DeepSeek Harness: sidebar workspace rows (via the sidebar.workspaces.row seam) and an input-row chip showing the current conversation's workspace git state. Event-driven freshness over SSE.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|