dsh-git-badge 0.6.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/lib/client.js CHANGED
@@ -1,11 +1,24 @@
1
1
  /**
2
2
  * dsh-git-badge — client half.
3
3
  *
4
+ * Both surfaces show the SAME status mark — a circle, or a tree for a linked
5
+ * worktree, filled with the status colour — so status and worktree-ness read
6
+ * identically wherever they appear.
7
+ *
4
8
  * Surfaces:
5
- * - sidebar.workspaces.row (seam): row badge — dot + branch
6
- * - conversation.input.left (upstream): chip — dot + branch + in-progress
7
- * operation token + sync/dirty suffix
8
- * The hover card is intentionally untouched.
9
+ * - sidebar.workspaces.row (seam): row badge — workspace name, the status mark
10
+ * floated right, and a worktree's name beside it. Deliberately shows NO
11
+ * branch: the chip is the surface that names it.
12
+ * - conversation.input.left (upstream): chip — status mark + branch + a
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.
9
22
  */
10
23
  window.__ModuleLoader__.load({
11
24
  id: "dsh-git-badge",
@@ -16,6 +29,24 @@ window.__ModuleLoader__.load({
16
29
  let react_jsx_runtime = require("react/jsx-runtime");
17
30
  let react = require("react");
18
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
+
19
50
  /** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
20
51
  const GIT_CACHE = new Map();
21
52
  /** In-flight fetches per cacheKey: bursts collapse into one request. */
@@ -24,16 +55,26 @@ window.__ModuleLoader__.load({
24
55
  const FALLBACK_POLL_MS = 60000;
25
56
 
26
57
  /**
27
- * Path equality for SSE notifications. The node half notifies with the
28
- * registered workspace path, while a surface may hold a variant spelling
29
- * (a trailing slash). A bare === there would silently kill that row's
30
- * freshness, so normalise the one difference we can cheaply normalise.
58
+ * A request target names WHO is asking, never a filesystem path — the node
59
+ * half resolves the directory itself, so a surface never needs to know
60
+ * one. The chip targets its conversation (`session`); a sidebar row
61
+ * targets the workspace id the seam hands it. The query string doubles as
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).
31
70
  */
32
- function samePath(a, b) {
33
- if (a === b) return true;
34
- if (typeof a !== "string" || typeof b !== "string") return false;
35
- const trim = (value) => value.length > 1 ? value.replace(/\/+$/, "") : value;
36
- return trim(a) === trim(b);
71
+ function targetQuery(target, options) {
72
+ if (target === void 0 || target.id === void 0) return void 0;
73
+ const param = target.kind === "workspace" ? "workspace" : "session";
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;
37
78
  }
38
79
 
39
80
  //#region SSE change feed (one EventSource per page, ref-counted)
@@ -49,19 +90,19 @@ window.__ModuleLoader__.load({
49
90
  // NAMED frames: the node half writes `event: change`, and onmessage
50
91
  // never fires for a named event
51
92
  eventSource.addEventListener("change", (message) => {
52
- let path;
93
+ let payload;
53
94
  try {
54
- path = JSON.parse(message.data).path;
95
+ payload = JSON.parse(message.data);
55
96
  } catch {
56
97
  return;
57
98
  }
58
- // invalidate every cache entry for this workspace, then refetch
59
- for (const key of [...GIT_CACHE.keys()]) {
60
- if (samePath(key, path)) GIT_CACHE.delete(key);
61
- }
99
+ // The server names the workspace it watched, so a subscriber that
100
+ // asked by id matches without ever knowing a path. Each
101
+ // subscriber refetches itself, and the per-key in-flight map
102
+ // collapses the resulting burst into one request.
62
103
  for (const fn of [...eventListeners]) {
63
104
  try {
64
- fn(path);
105
+ fn(payload);
65
106
  } catch {
66
107
  /* one bad subscriber must not starve the rest */
67
108
  }
@@ -81,76 +122,130 @@ window.__ModuleLoader__.load({
81
122
 
82
123
  /**
83
124
  * Shared git-status hook. Fetches once on mount and then only when the
84
- * node half's watcher reports a change for this workspace (SSE), plus a
85
- * slow safety-net poll in case the stream dies silently. Returns
86
- * undefined while loading and for non-git paths.
125
+ * node half's watcher reports a change for this target (SSE), plus a slow
126
+ * safety-net poll in case the stream dies silently. Returns undefined
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.
87
135
  */
88
- function useGitStatus(cwd) {
89
- const cacheKey = cwd;
136
+ function useGitStatus(target, options) {
137
+ const enabled = options?.enabled !== false;
138
+ const cacheKey = targetQuery(target, options);
90
139
  const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
91
- const [info, setInfo] = react.useState(hit !== void 0 ? hit.data : void 0);
140
+ // state carries the key it belongs to: switching conversations changes
141
+ // the key, and showing the previous session's badge until the new fetch
142
+ // lands would be wrong
143
+ const [state, setState] = react.useState({ key: cacheKey, data: hit === void 0 ? void 0 : hit.data });
92
144
  react.useEffect(() => {
93
- if (cacheKey === void 0) return;
94
- let alive = true;
95
- const apply = (data) => {
96
- // A degraded response (git timeout/failure) must never clobber a
97
- // good cached badge — keep the last-known state until a real
98
- // event or the fallback poll succeeds.
99
- if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
100
- GIT_CACHE.set(cacheKey, { at: Date.now(), data });
101
- if (alive) setInfo(data);
102
- };
103
- const load = () => {
104
- // dedupe: an in-flight fetch for this key serves all callers
105
- let pending = GIT_INFLIGHT.get(cacheKey);
106
- if (pending === void 0) {
107
- pending = fetch("/api/git-badge?path=" + encodeURIComponent(cwd))
108
- .then((r) => r.json())
109
- .finally(() => GIT_INFLIGHT.delete(cacheKey));
110
- GIT_INFLIGHT.set(cacheKey, pending);
145
+ if (cacheKey === void 0 || !enabled) return;
146
+ let alive = true;
147
+ // The workspace this target resolved to, taken from the response (the
148
+ // server echoes it). THIS is what events are matched against: an event
149
+ // carries a workspace id, and a session-targeted chip has only a
150
+ // session id, so comparing the two directly never matches.
151
+ let resolvedWorkspace;
152
+ // Re-seed from cache when the key changes (switching conversations):
153
+ // render the last-known badge immediately instead of blanking until the
154
+ // fetch lands, and take the workspace id from it so events still match.
155
+ const cached = GIT_CACHE.get(cacheKey);
156
+ if (cached !== void 0) {
157
+ if (typeof cached.data?.workspace === "string") resolvedWorkspace = cached.data.workspace;
158
+ setState({ key: cacheKey, data: cached.data });
111
159
  }
112
- pending.then(apply).catch(() => {});
113
- };
114
- load();
115
- // this workspace's watcher events → refetch
116
- const unsubscribe = subscribeGitEvents((path) => {
117
- if (samePath(path, cwd)) load();
118
- });
119
- const fallback = setInterval(load, FALLBACK_POLL_MS);
120
- return () => {
121
- alive = false;
122
- unsubscribe();
123
- clearInterval(fallback);
124
- };
125
- }, [cacheKey]);
126
- return info;
160
+ const apply = (data) => {
161
+ // A degraded response (git timeout/failure) must never clobber a
162
+ // good cached badge — keep the last-known state until a real
163
+ // event or the fallback poll succeeds.
164
+ if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
165
+ if (data !== null && typeof data.workspace === "string") resolvedWorkspace = data.workspace;
166
+ GIT_CACHE.set(cacheKey, { at: Date.now(), data });
167
+ if (alive) setState({ key: cacheKey, data });
168
+ };
169
+ const load = () => {
170
+ // dedupe: an in-flight fetch for this key serves all callers
171
+ let pending = GIT_INFLIGHT.get(cacheKey);
172
+ if (pending === void 0) {
173
+ pending = fetch("/api/git-badge?" + cacheKey)
174
+ .then((r) => r.json())
175
+ .finally(() => GIT_INFLIGHT.delete(cacheKey));
176
+ GIT_INFLIGHT.set(cacheKey, pending);
177
+ }
178
+ pending.then(apply).catch(() => {});
179
+ };
180
+ load();
181
+ const unsubscribe = subscribeGitEvents((payload) => {
182
+ // An event with no workspace id (no watcher record for that path)
183
+ // cannot be attributed, and neither can one arriving before we have
184
+ // resolved our own target — refetch rather than guess, since a
185
+ // missed refresh is the bug this guards and an extra request is cheap
186
+ // (the in-flight map collapses bursts).
187
+ if (payload.workspace === void 0 || resolvedWorkspace === void 0 || payload.workspace === resolvedWorkspace) load();
188
+ });
189
+ const fallback = setInterval(load, FALLBACK_POLL_MS);
190
+ return () => {
191
+ alive = false;
192
+ unsubscribe();
193
+ clearInterval(fallback);
194
+ };
195
+ }, [cacheKey, enabled]);
196
+ return state.key === cacheKey ? state.data : void 0;
127
197
  }
128
198
 
129
199
  /**
130
- * Three-state dot color, shared by the sidebar row badge and the
131
- * composer chip. Checked top-down, first match wins:
132
- * - RED: unmerged files (mid-conflict, git is blocked) — or a dirty
200
+ * Three-state status, checked top-down, first match wins:
201
+ * - "error": unmerged files (mid-conflict, git is blocked) — or a dirty
133
202
  * tree that is ALSO behind upstream (unsaved edits on an outdated
134
203
  * base: commit-then-pull friction ahead).
135
- * - YELLOW: dirty files, or any ahead/behind (routine work or sync
204
+ * - "warn": dirty files, or any ahead/behind (routine work or sync
136
205
  * pending — including the previously "quiet" green-but-↓1 case).
137
- * - GREEN: clean and in sync.
206
+ * - "ok": clean and in sync.
138
207
  * Red never means merely "behind": a clean tree one commit behind is
139
208
  * normal between pulls, not an alarm.
209
+ *
210
+ * The single source of truth for that summary. BOTH surfaces render it the
211
+ * same way (see StatusMark): a circle, or a tree in a linked worktree,
212
+ * filled with the colour below.
140
213
  */
141
- function badgeDot(info) {
214
+ function badgeStatus(info) {
142
215
  const dirty = info.dirty === true;
143
216
  const behind = (info.behind || 0) > 0;
144
217
  const ahead = (info.ahead || 0) > 0;
145
218
  const conflict = (info.unmergedFiles || 0) > 0;
146
- if (conflict || (dirty && behind)) return "\uD83D\uDD34";
147
- if (dirty || ahead || behind) return "\uD83D\uDFE1";
148
- return "\uD83D\uDFE2";
219
+ if (conflict || (dirty && behind)) return "error";
220
+ if (dirty || ahead || behind) return "warn";
221
+ return "ok";
149
222
  }
150
223
 
151
224
  /**
152
- * Sync/dirty suffix — INPUT CHIP ONLY. The sidebar row badge shows
153
- * status + branch alone; the chip is the detailed surface. Rule set:
225
+ * Mark fill per status, from the app's own semantic tokens so BOTH surfaces
226
+ * follow light/dark and custom themes — which a hardcoded emoji cannot do.
227
+ * The literals are only fallbacks for use outside DSH.
228
+ */
229
+ const MARK_FILL = {
230
+ error: "var(--dsw-alias-state-error-primary, #e5484d)",
231
+ warn: "var(--dsw-alias-state-warn-primary, #d29922)",
232
+ ok: "var(--dsw-alias-state-success-primary, #30a46c)"
233
+ };
234
+
235
+ /**
236
+ * Accessible name per status, WITHOUT the "git" prefix — the mark adds
237
+ * "git: " or "git worktree: ", so shape and colour are never the only
238
+ * channel for either fact.
239
+ */
240
+ const STATUS_LABEL = {
241
+ error: "conflict, or uncommitted changes on an outdated base",
242
+ warn: "uncommitted changes, or out of sync with upstream",
243
+ ok: "clean and in sync"
244
+ };
245
+
246
+ /**
247
+ * Sync/dirty suffix — INPUT CHIP ONLY. The sidebar row shows status alone;
248
+ * the chip is the surface that carries the numbers. Rule set:
154
249
  * - dirty files render as ✎n (pencil = worktree files, distinct from
155
250
  * the ↑/↓ commit-sync axis)
156
251
  * - while dirty, BOTH sync counts render including zeros (↑0 ↓2 ✎3),
@@ -171,9 +266,60 @@ window.__ModuleLoader__.load({
171
266
  }
172
267
 
173
268
  /**
174
- * In-progress operation token — INPUT CHIP ONLY (the row badge stays
175
- * dot + branch). A paused rebase or cherry-pick whose conflicts are all
176
- * already staged has no unmerged files, so the three-state dot cannot
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
+
319
+ /**
320
+ * In-progress operation token — INPUT CHIP ONLY (the row shows the status
321
+ * tree alone). A paused rebase or cherry-pick whose conflicts are all
322
+ * already staged has no unmerged files, so the three-state summary cannot
177
323
  * distinguish it from ordinary dirty work; this says which
178
324
  * history-rewriting operation git is waiting on.
179
325
  */
@@ -193,6 +339,45 @@ window.__ModuleLoader__.load({
193
339
  return label === void 0 ? "" : " " + label;
194
340
  }
195
341
 
342
+ /**
343
+ * Linked-worktree NAME token — INPUT CHIP ONLY, and only the name: worktree-
344
+ * ness itself is carried by the status mark's shape (a tree instead of a
345
+ * circle) on both surfaces. The chip has no other workspace identity, so
346
+ * several worktrees of one repository would otherwise render identical chips
347
+ * with no way to tell which checkout a conversation is in. The node half
348
+ * reports whether this is a linked worktree plus its directory NAME (never a
349
+ * path).
350
+ *
351
+ * The name is appended only when it says something the branch does not: the
352
+ * usual `repo-feat-x` directory sitting on branch `feat-x` would otherwise
353
+ * read as stutter. (The ROW needs no such rule — it shows the name instead
354
+ * of the branch, so there is nothing to stutter against.)
355
+ */
356
+
357
+ /**
358
+ * True when the worktree name is already implied by the branch name, so
359
+ * showing both is pure repetition. Compared on a normalized form —
360
+ * case-folded, with `/` and `_` folded to `-` — because directory and
361
+ * branch conventions differ without changing the meaning (`feat/x` in a
362
+ * `repo-feat-x` directory).
363
+ */
364
+ function worktreeNameIsRedundant(name, branch) {
365
+ if (typeof name !== "string" || name === "" || typeof branch !== "string") return true;
366
+ const normalize = (value) => value.toLowerCase().replace(/[/_]/g, "-");
367
+ const normalizedName = normalize(name);
368
+ const normalizedBranch = normalize(branch);
369
+ return normalizedName === normalizedBranch
370
+ || normalizedName.endsWith("-" + normalizedBranch)
371
+ || normalizedBranch.endsWith("-" + normalizedName);
372
+ }
373
+
374
+ /** ` name` for a worktree whose name the branch does not already say, else "". */
375
+ function formatWorktreeToken(info) {
376
+ if (info.isWorktree !== true) return "";
377
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
378
+ return worktreeNameIsRedundant(name, info.branch) ? "" : " " + name;
379
+ }
380
+
196
381
  const META_STYLE = {
197
382
  color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
198
383
  fontSize: "12px",
@@ -202,71 +387,234 @@ window.__ModuleLoader__.load({
202
387
  };
203
388
 
204
389
  /**
205
- * Row badge — three-state dot + branch (sync/dirty detail lives on
206
- * the input chip):
207
- * the_paragliding_app | 🟢 main
208
- * Dot color via badgeDot(): green clean+synced, yellow dirty or
209
- * out-of-sync, red conflict or dirty-and-behind.
210
- * Always renders the workspace name (so the row keeps its identity);
211
- * appends the muted `| emoji branch` part only for git workspaces.
390
+ * Status mark, SHARED BY BOTH SURFACES: the same shape in the same fill
391
+ * wherever it appears, so the sidebar row and the input chip read
392
+ * identically. The FILL is the status colour; the SHAPE carries
393
+ * worktree-ness — a circle for a main checkout, a tree for a linked
394
+ * worktree — so the two facts never compete for the same channel.
395
+ *
396
+ * Drawn rather than typed because 🌳 is a COLOUR EMOJI: CSS cannot tint
397
+ * its leaves, and a mark that carries the status has to be paintable.
398
+ * Three overlapping crown lobes plus a trunk stay legible at this size.
399
+ *
400
+ * Neither colour nor shape is the only channel: the accessible name
401
+ * states both.
402
+ */
403
+ function StatusMark({ info }) {
404
+ const status = badgeStatus(info);
405
+ const fill = MARK_FILL[status];
406
+ const isWorktree = info.isWorktree === true;
407
+ const svg = {
408
+ // 14px, NOT 12px: a colour emoji draws well above its nominal size — the
409
+ // 🔴/🟡/🟢 dot this replaces put down ~13-14px of ink at the surfaces'
410
+ // 12px font-size, so a 12px box read as a shrunken dot.
411
+ width: 14,
412
+ height: 14,
413
+ viewBox: "0 0 12 12",
414
+ role: "img",
415
+ "aria-label": (isWorktree ? "git worktree: " : "git: ") + STATUS_LABEL[status],
416
+ focusable: "false",
417
+ style: { flex: "none", display: "block", color: META_STYLE.color }
418
+ };
419
+ if (!isWorktree) {
420
+ // a plain filled disc: this is a main checkout. It fills its box so the
421
+ // ink lands at the size the emoji dot used to.
422
+ return react_jsx_runtime.jsx("svg", { ...svg, children: react_jsx_runtime.jsx("circle", { cx: 6, cy: 6, r: 5.5, fill }) });
423
+ }
424
+ return react_jsx_runtime.jsxs("svg", {
425
+ ...svg,
426
+ children: [
427
+ // trunk first, so the crown's lobes overlap and join it. These are the
428
+ // 12px-era coordinates scaled 1.15 about the tree's centre, so its ink
429
+ // stays comparable to the disc's instead of reading small beside it.
430
+ react_jsx_runtime.jsx("rect", { key: "trunk", x: 5.25, y: 7.3, width: 1.5, height: 4.14, rx: 0.63, fill: "currentColor" }),
431
+ react_jsx_runtime.jsx("circle", { key: "crown-1", cx: 6, cy: 3.62, r: 3.57, fill }),
432
+ react_jsx_runtime.jsx("circle", { key: "crown-2", cx: 3.24, cy: 5.46, r: 2.53, fill }),
433
+ react_jsx_runtime.jsx("circle", { key: "crown-3", cx: 8.76, cy: 5.46, r: 2.53, fill })
434
+ ]
435
+ });
436
+ }
437
+
438
+ /**
439
+ * A linked worktree's directory name, or null when there is nothing to
440
+ * show. A main checkout shows NO text: the row surveys status and identity,
441
+ * and the branch lives on the input chip — which is why the branch is never
442
+ * rendered here. The name is the one thing the row's own label (the
443
+ * worktree directory by default) cannot be trusted to say once a workspace
444
+ * has been renamed.
212
445
  */
213
- function WorkspaceGitBadge({ label, cwd }) {
214
- // no detail=1: the row renders dot + branch only, and the extra log /
215
- // stash calls have no consumer yet
216
- const info = useGitStatus(cwd);
446
+ function rowName(info) {
447
+ if (info.isWorktree !== true) return null;
448
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
449
+ return name === "" ? null : name;
450
+ }
451
+
452
+ /**
453
+ * Row badge — workspace name on the left, the status mark floated right:
454
+ * the_paragliding_app ●
455
+ * worktree_thing hotfix-tree 🌳
456
+ * Fill colour via badgeStatus(): green clean+synced, amber dirty or
457
+ * out-of-sync, red conflict or dirty-and-behind. Shape via the node half's
458
+ * isWorktree: circle = main checkout, tree = linked worktree.
459
+ * Always renders the workspace name (so the row keeps its identity) and
460
+ * appends the right-hand mark only for git workspaces. No branch, and no
461
+ * `|` separator.
462
+ *
463
+ * Targets the row's `workspaceId`. The seam also passes `cwd`, which is
464
+ * deliberately ignored: the node half resolves the directory itself, so
465
+ * the client never has to name one. A row with no workspaceId (the
466
+ * ungrouped bucket) has no workspace to report on, so it renders
467
+ * name-only.
468
+ */
469
+ function WorkspaceGitBadge({ label, workspaceId }) {
470
+ // no detail=1: the row needs status and identity only, and the extra
471
+ // log / stash calls have no consumer yet
472
+ const info = useGitStatus(workspaceId === void 0 ? void 0 : { kind: "workspace", id: workspaceId });
217
473
  const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
218
- if (cwd !== void 0 && info !== void 0 && info.git === true) {
219
- children.push(
220
- react_jsx_runtime.jsx("span", { style: { ...META_STYLE, margin: "0 7px" }, children: "|" }),
221
- react_jsx_runtime.jsx("span", { style: { ...META_STYLE, marginRight: "4px" }, children: badgeDot(info) }),
222
- react_jsx_runtime.jsx("span", { style: META_STYLE, children: info.branch })
223
- );
474
+ if (workspaceId !== void 0 && info !== void 0 && info.git === true) {
475
+ // marginLeft:auto floats the mark to the right edge, so the status
476
+ // shape holds a fixed right-hand column and a worktree's name grows
477
+ // leftwards from it instead of shoving the shape around.
478
+ const meta = [];
479
+ const name = rowName(info);
480
+ if (name !== null) meta.push(react_jsx_runtime.jsx("span", { key: "name", style: META_STYLE, children: name }));
481
+ meta.push(react_jsx_runtime.jsx(StatusMark, { key: "mark", info }));
482
+ children.push(react_jsx_runtime.jsx("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flex: "none", marginLeft: "auto", paddingLeft: "8px" }, children: meta }));
224
483
  }
225
- return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0 }, children });
484
+ return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
226
485
  }
227
486
 
228
- //#region composer chip (fallback + companion surface: conversation.composer.dock)
229
- /** Client root ctx, captured at apply() for the chip's service lookups. */
230
- let clientCtx = null;
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" };
231
526
 
232
527
  /**
233
- * Resolve the workspace cwd attached to a session: projects the
234
- * workspaces service's snapshot store (items carry path + sessionIds)
235
- * and re-resolves on change. Returns undefined while unknown.
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.
236
532
  */
237
- function useSessionWorkspaceCwd(sessionId) {
238
- const workspaces = clientCtx === null ? null : clientCtx.get("workspaces");
239
- const resolve = () => {
240
- if (sessionId === void 0 || workspaces === void 0) return void 0;
241
- const item = workspaces.list.getSnapshot().items.find((w) => w.sessionIds !== void 0 && w.sessionIds.includes(sessionId));
242
- return item === void 0 ? void 0 : item.path;
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
+ );
243
549
  };
244
- const [cwd, setCwd] = react.useState(resolve);
245
- react.useEffect(() => {
246
- if (sessionId === void 0 || workspaces === void 0) return;
247
- setCwd(resolve());
248
- return workspaces.list.subscribe(() => {
249
- setCwd(resolve());
250
- });
251
- }, [sessionId, workspaces]);
252
- return cwd;
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 });
253
581
  }
254
582
 
583
+ //#region composer chip (upstream additive surface: conversation.input.left)
255
584
  /**
256
- * Chip line docked at the composer: git state of the workspace the
257
- * CURRENT conversation is attached to. Works on unpatched installs
258
- * (conversation.composer.dock is an upstream additive slot), and stays
259
- * useful next to the sidebar rows on patched/upstream-seam installs
260
- * because it is context-anchored ("where am I") rather than surveying.
585
+ * Chip line in the input row: git state of the workspace the CURRENT
586
+ * conversation is attached to. It targets the session id and lets the node
587
+ * half resolve the workspace, so this surface needs no `workspaces`
588
+ * service, no cwd and no path plumbing at all. Works on unpatched installs
589
+ * (conversation.input.left is an upstream additive slot), and stays useful
590
+ * next to the sidebar rows because it is context-anchored ("where am I")
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.
261
596
  */
262
597
  function ComposerGitChip({ sessionId }) {
263
- const cwd = useSessionWorkspaceCwd(sessionId);
264
- // no detail=1 either: nothing renders lastCommits / stashCount yet, and
265
- // asking for them would add a log -3 plus a stash list to every refresh
266
- const info = useGitStatus(cwd);
267
- if (cwd === void 0 || info === void 0 || info.git !== true) return null;
268
- const text = badgeDot(info) + " " + info.branch + formatOperationToken(info) + formatGitSuffix(info);
269
- return react_jsx_runtime.jsx("span", {
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 });
610
+ if (info === void 0 || info.git !== true) return null;
611
+ // the mark is an element now rather than a leading glyph in the string, so
612
+ // the SAME StatusMark the sidebar row draws carries the status here too;
613
+ // the container's 4px gap supplies the space the emoji's own did
614
+ const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
615
+ const prToken = formatPrToken(info);
616
+ const prUrl = prLinkUrl(info);
617
+ const chip = react_jsx_runtime.jsxs("span", {
270
618
  style: {
271
619
  display: "inline-flex",
272
620
  alignItems: "center",
@@ -278,28 +626,98 @@ window.__ModuleLoader__.load({
278
626
  whiteSpace: "nowrap",
279
627
  cursor: "default"
280
628
  },
281
- children: text
629
+ // the card is fetched for a pointer that RESTS here, not one that
630
+ // merely crosses the chip
631
+ onPointerEnter: () => setHovered(true),
632
+ children: [
633
+ react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
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
+ })
672
+ ]
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
282
685
  });
283
686
  }
284
687
  //#endregion
285
688
 
286
- const inject = ["slots", "workspaces"];
689
+ // `workspaces` is no longer required: both surfaces target an id and the
690
+ // node half resolves the workspace, so the client never needs a service
691
+ // lookup. Fewer declared services also means fewer ways to fail to load.
692
+ const inject = ["slots"];
287
693
 
288
694
  /**
289
695
  * Register the badge into both seams. The seam owner hands each entry the
290
- * row owner share as props; the badge destructures { workspaceId, cwd,
291
- * label } from it.
696
+ * row owner share as props; the badge destructures { workspaceId, label }
697
+ * and deliberately ignores the cwd it is also given.
292
698
  */
293
699
  function apply(ctx) {
294
- clientCtx = ctx;
295
700
  // inject() re-evaluates when a seam's declaration appears, so boot
296
701
  // order relative to the workspace browser does not matter. On an
297
- // unpatched install the row seams never get declared, so those two
298
- // registrations simply never render anything.
299
- ctx.slots.inject("sidebar.workspaces.row", () => ctx.slots.register({
300
- name: "sidebar.workspaces.row",
301
- id: "git-badge"
302
- }, WorkspaceGitBadge));
702
+ // unpatched install the row seam is never declared, so this callback
703
+ // never fires and the row badge simply never renders.
704
+ //
705
+ // The registration is reported from INSIDE the callback on purpose: a
706
+ // `spec()` check here at apply() time runs before the workspace browser
707
+ // declares the seam, so it reports the boot race rather than the
708
+ // outcome. That mistake made the old one-shot line claim "sidebar rows
709
+ // = off" while five row badges were rendering.
710
+ let rowsReported = false;
711
+ ctx.slots.inject("sidebar.workspaces.row", () => {
712
+ if (!rowsReported) {
713
+ rowsReported = true;
714
+ console.info("[dsh-git-badge] surfaces: sidebar rows = on (seam present).");
715
+ }
716
+ return ctx.slots.register({
717
+ name: "sidebar.workspaces.row",
718
+ id: "git-badge"
719
+ }, WorkspaceGitBadge);
720
+ });
303
721
  // Input-row chip: upstream additive slot rendered in the input bar's
304
722
  // leading cluster, right after the access picker — the git state sits
305
723
  // with the controls that govern the conversation. Present on every
@@ -312,11 +730,26 @@ window.__ModuleLoader__.load({
312
730
  inject: (sessionId) => ({ sessionId })
313
731
  }, ComposerGitChip));
314
732
  const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
315
- console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)" : "off (seam absent — sidebar badges need the sidebar.workspaces.row seam)") + ".");
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
+ );
316
744
  }
317
745
 
318
746
  exports.apply = apply;
319
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 };
320
753
  return module.exports;
321
754
  }
322
755
  });