dsh-git-badge 0.16.1 → 0.17.1

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
@@ -56,8 +56,17 @@ window.__ModuleLoader__.load({
56
56
  const Menu = primitives === null ? void 0 : primitives.Menu;
57
57
  const rankByName = primitives === null ? void 0 : primitives.rankByName;
58
58
 
59
- /** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
59
+ /**
60
+ * Module-level git-status cache. Entries are invalidated by SSE events,
61
+ * never by age — and BOUNDED, because a long-lived tab opens and closes
62
+ * many sessions and nothing else removes an entry. When the cap is
63
+ * exceeded the oldest-INSERTED key is evicted (Map order is insertion,
64
+ * not use); an evicted surface simply refetches, which the in-flight map
65
+ * collapses like any other request.
66
+ */
60
67
  const GIT_CACHE = new Map();
68
+ /** The cache's entry bound — each surface is one key, plus lazy detail keys. */
69
+ const GIT_CACHE_LIMIT = 64;
61
70
  /** In-flight fetches per cacheKey: bursts collapse into one request. */
62
71
  const GIT_INFLIGHT = new Map();
63
72
  /** Slow safety-net poll: refreshes even if the SSE stream is silently dead. */
@@ -138,7 +147,7 @@ window.__ModuleLoader__.load({
138
147
  * `options.detail` / `options.pr` ask the node half for the chip's extras
139
148
  * (see targetQuery). `options.enabled: false` holds the fetch back
140
149
  * entirely — that is what makes the hover card's extra fields LAZY: the
141
- * chip renders without them and only starts paying for `log -3` plus a
150
+ * chip renders without them and only starts paying for a `log -10` plus a
142
151
  * stash list once a pointer actually rests on it. The hook is still called
143
152
  * unconditionally (hooks may not be conditional); the GATE is inside.
144
153
  */
@@ -173,6 +182,9 @@ window.__ModuleLoader__.load({
173
182
  if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
174
183
  if (data !== null && typeof data.workspace === "string") resolvedWorkspace = data.workspace;
175
184
  GIT_CACHE.set(cacheKey, { at: Date.now(), data });
185
+ if (GIT_CACHE.size > GIT_CACHE_LIMIT) {
186
+ GIT_CACHE.delete(GIT_CACHE.keys().next().value);
187
+ }
176
188
  if (alive) setState({ key: cacheKey, data });
177
189
  };
178
190
  const load = () => {
@@ -232,45 +244,37 @@ window.__ModuleLoader__.load({
232
244
 
233
245
 
234
246
  /**
235
- * The /gh picker's sub-actions — the skill invocations the checkout
236
- * justifies, most urgent first. The server's `next` (the ranked top rule,
237
- * already carrying args/why/what) always leads; the client adds the other
238
- * justified actions as secondary entries, mirroring the server's category
239
- * rules but never re-deciding the winner.
247
+ * The actions BOTH surfaces render — the server's list, verbatim.
248
+ *
249
+ * This half used to re-derive pull/push/commit/pr from the raw counts,
250
+ * which meant two implementations of the same conditions drifting apart:
251
+ * the card once promoted a client-only extra into its action row, and
252
+ * `/gh pr` was offered on the default branch. The node half owns the
253
+ * rules and the ranking now; the client adds presentation only — the risk
254
+ * gate for `clean`, and the labels.
240
255
  */
241
256
  function ghSkillActions(info) {
242
257
  if (info === void 0 || info === null || info.git !== true) return [];
243
- const subs = [];
244
- const seen = new Set();
245
- const add = (args, why, what) => {
246
- if (args === void 0 || args === null || args === "" || seen.has(args)) return;
247
- seen.add(args);
248
- subs.push({ args, why, what });
258
+ return Array.isArray(info.actions) ? info.actions : [];
259
+ }
260
+
261
+ /**
262
+ * The risk gate the menu shows for `/gh clean`, in the host's own
263
+ * confirmation shape (RiskConfirmation: title, description, an acknowledge
264
+ * checkbox, two button labels). Confirm stays disabled until the box is
265
+ * ticked — that is the "are you sure?", before the invocation is even sent.
266
+ * The agent then re-establishes the exact list with `git clean -n` and asks
267
+ * again: two gates, because this one names the risk and that one names the
268
+ * files.
269
+ */
270
+ function cleanGate() {
271
+ return {
272
+ title: "Remove untracked files?",
273
+ description: "Untracked files have no reflog and no stash entry — once removed they cannot be recovered. The agent will show the exact list (`git clean -n`) and ask again before removing anything.",
274
+ acknowledgeLabel: "I understand untracked files cannot be recovered",
275
+ cancelLabel: "Cancel",
276
+ confirmLabel: "Continue"
249
277
  };
250
- const next = info.next;
251
- if (next !== void 0 && next !== null) add(next.args, next.why, next.what);
252
- const ahead = info.ahead || 0;
253
- const behind = info.behind || 0;
254
- const staged = info.stagedFiles || 0;
255
- const unstaged = info.unstagedFiles || 0;
256
- const untracked = info.untrackedFiles || 0;
257
- if (behind > 0 && ahead > 0) {
258
- add("sync", ahead + " ahead, " + behind + " behind — diverged", "rebase your commits onto upstream and push (asks before any force)");
259
- } else {
260
- if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"), "update this branch from upstream");
261
- if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"), "push local commits to the remote");
262
- }
263
- if (staged > 0) add("commit", staged + " staged", "commit the staged changes");
264
- if (unstaged > 0) add("commit", unstaged + " unstaged", "stage and commit the working changes");
265
- if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked", "stage the new files and commit them");
266
- const pr = info.pr;
267
- if (pr !== void 0 && pr !== null && pr.number !== void 0) {
268
- if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number, "watch the CI checks on pull request " + pr.number);
269
- add("pr view " + pr.number, "open pull request #" + pr.number, "show pull request " + pr.number + " on GitHub");
270
- } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
271
- add("pr", "branch is pushed and has no pull request", "open a pull request for this branch");
272
- }
273
- return subs.slice(0, 6);
274
278
  }
275
279
 
276
280
  /**
@@ -305,16 +309,29 @@ window.__ModuleLoader__.load({
305
309
  * - clean workspaces stay quiet: arrows only when nonzero, no ✎ at 0
306
310
  * - no upstream: arrows omitted entirely
307
311
  */
308
- function formatGitSuffix(info) {
309
- const parts = [];
312
+ /**
313
+ * The upstream sync arrows (" ↑1 ↓2"), or "". Split out from the file
314
+ * count so each rides the surface that OWNS it: the arrows belong to the
315
+ * branch (they are the branch's standing against its upstream, and the
316
+ * branch name's hover and compare link cover them), while ✎n belongs to the
317
+ * count's names tooltip.
318
+ */
319
+ function formatSync(info) {
310
320
  const hasUpstream = info.ahead !== void 0 || info.behind !== void 0;
311
321
  const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
312
- if (hasUpstream && (files > 0 || info.ahead > 0 || info.behind > 0)) {
313
- parts.push("\u2191" + (info.ahead || 0));
314
- parts.push("\u2193" + (info.behind || 0));
315
- }
316
- if (files > 0) parts.push("\u270E" + files);
317
- return parts.length > 0 ? " " + parts.join(" ") : "";
322
+ if (!hasUpstream || (files === 0 && !(info.ahead > 0) && !(info.behind > 0))) return "";
323
+ return " \u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0);
324
+ }
325
+
326
+ /** The ✎n file count (" ✎3"), or "". */
327
+ function formatFileCount(info) {
328
+ const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
329
+ return files > 0 ? " \u270E" + files : "";
330
+ }
331
+
332
+ /** Both halves, in chip order — kept for callers that want the whole suffix. */
333
+ function formatGitSuffix(info) {
334
+ return formatSync(info) + formatFileCount(info);
318
335
  }
319
336
 
320
337
  /**
@@ -544,9 +561,57 @@ window.__ModuleLoader__.load({
544
561
  */
545
562
  const ACTION_STYLE = { flex: "none", marginLeft: "auto", marginRight: "8px", fontWeight: 500 };
546
563
 
547
- /** The action badge: floated, weighted, and coloured by severity. */
548
- function SessionGitBadge({ sessionId }) {
564
+ /**
565
+ * The row's own run state, from the shell's standard kit.
566
+ *
567
+ * The renderer that assembles slots hands EVERY entry the sessions store as
568
+ * a selector hook (`useSessions`) — the same kit that supplies
569
+ * `useWorkspaces` — which is how a row can tell an idle session from one
570
+ * mid-turn without the seam having to carry it. A shell that does not seed
571
+ * that kit leaves the prop undefined: then we cannot know, and the badge
572
+ * behaves exactly as it did before rather than guessing "idle".
573
+ *
574
+ * One helper and one call site, so the hook order is identical on every path
575
+ * (the fallback is a plain function, never a hook).
576
+ */
577
+ function useSessionRunning(useSessions, sessionId) {
578
+ const select = typeof useSessions === "function" ? useSessions : () => void 0;
579
+ return select((state) => state?.byId?.[sessionId]?.running) === true;
580
+ }
581
+
582
+ /**
583
+ * Report the idle gate ONCE, the way the boot diagnostics report the seam and
584
+ * the Tooltip primitive. Whether the shell actually handed a slot the
585
+ * sessions store is invisible from anywhere else, and a gate that silently
586
+ * never fires is indistinguishable from a gate that never needed to.
587
+ */
588
+ let idleGateReported = false;
589
+ function reportIdleGate(present) {
590
+ if (idleGateReported) return;
591
+ idleGateReported = true;
592
+ console.info(
593
+ "[dsh-git-badge] session-row idle gate = " +
594
+ (present
595
+ ? "on (the standard kit supplies useSessions)."
596
+ : "off (no useSessions prop) — the token cannot tell a running turn, so it shows as before.")
597
+ );
598
+ }
599
+
600
+ /**
601
+ * The action badge: floated, weighted, and coloured by severity — and shown
602
+ * only when the session is IDLE. A turn in flight is precisely when a git
603
+ * suggestion is noise: the checkout is still moving and the agent has not
604
+ * finished, so the action describes a state that is about to change. Nothing
605
+ * about the action itself changes; the gate is the whole feature.
606
+ *
607
+ * The filter is on RENDER, not on the fetch: hiding the row must not take the
608
+ * hover card's detail line down with it — both read the same cached status.
609
+ */
610
+ function SessionGitBadge({ sessionId, useSessions }) {
549
611
  const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
612
+ const running = useSessionRunning(useSessions, sessionId);
613
+ reportIdleGate(typeof useSessions === "function");
614
+ if (running) return null;
550
615
  if (sessionId === void 0 || info === void 0 || info.git !== true) return null;
551
616
  const action = actionToken(info);
552
617
  if (action === "") return null;
@@ -651,6 +716,70 @@ window.__ModuleLoader__.load({
651
716
  return name;
652
717
  }
653
718
 
719
+ /**
720
+ * ONE commit line, shared by the branch hover and the PR token's hover so
721
+ * the two cannot drift apart again: a fixed-width monospace hash cell, so
722
+ * every description starts on the same column and the list reads as a
723
+ * table, then `subject · age`. `sign` belongs to the branch hover — "+"
724
+ * (only on this branch) or "−" (only on main); the PR list passes none,
725
+ * because all of its commits are `base..HEAD`.
726
+ *
727
+ * The cell needs `display: inline-block` for `minWidth` to mean anything: an
728
+ * inline span ignores it, which is exactly how the PR list came to render a
729
+ * hash with NO gap at all — it reused the card's label style, whose
730
+ * `minWidth` and `flex` only do anything inside a flex row.
731
+ */
732
+ function CommitLine({ commit, sign = "" }) {
733
+ return react_jsx_runtime.jsxs("span", {
734
+ style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
735
+ children: [
736
+ react_jsx_runtime.jsx("span", {
737
+ style: {
738
+ color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
739
+ fontFamily: "monospace",
740
+ display: "inline-block",
741
+ minWidth: "68px",
742
+ flex: "none"
743
+ },
744
+ children: sign + commit.hash
745
+ }),
746
+ commit.subject + (commit.when === "" ? "" : " \u00B7 " + shortWhen(commit.when))
747
+ ]
748
+ });
749
+ }
750
+
751
+ /**
752
+ * The PR token's own hover body — the commits IN THIS PULL REQUEST
753
+ * (`base..HEAD`), and nothing else: the token already says the number and
754
+ * CI state, so the one question left is "what is in it?". Same line format
755
+ * as the branch hover's commit list (see CommitLine — one renderer, so the
756
+ * columns line up in both by construction), and the card's own width
757
+ * behaviour: full-width lines, nowrap, ellipsis.
758
+ */
759
+ function PrCommitsList({ info, pending = false }) {
760
+ const commits = info?.prCommits;
761
+ if (!Array.isArray(commits) || commits.length === 0) {
762
+ // `pending`: the detail payload has not landed yet, so "no commits"
763
+ // would be a false negative — name the pull request the token points
764
+ // at instead, the way the mark's card degrades to the base response.
765
+ if (pending) {
766
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: formatPrDetail(info?.pr) ?? "loading\u2026" });
767
+ }
768
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no commits beyond the base branch" });
769
+ }
770
+ return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children: [
771
+ react_jsx_runtime.jsxs("div", {
772
+ style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
773
+ children: [
774
+ ...commits.map((commit) => react_jsx_runtime.jsx(CommitLine, { commit }, commit.hash)),
775
+ (typeof info.prCommitsTotal === "number" && info.prCommitsTotal > commits.length
776
+ ? "\u2026 and " + (info.prCommitsTotal - commits.length) + " more"
777
+ : null)
778
+ ]
779
+ })
780
+ ] });
781
+ }
782
+
654
783
  const CARD_CONTAINER = {
655
784
  display: "flex",
656
785
  flexDirection: "column",
@@ -669,12 +798,13 @@ window.__ModuleLoader__.load({
669
798
  * the one question the count poses ("what are these?"). One stacked
670
799
  * column with sub-headers: UNSTAGED (tracked, edited — valid regardless
671
800
  * of the collapsed untracked retry) then UNTRACKED. Reads the same lazy
672
- * detail payload the mark's card uses; no tooltip at all when neither
673
- * list has names (the count itself is absent on a clean tree, so an
674
- * empty list is rare — a mid-fetch hover shows it plain until the
675
- * response lands).
801
+ * detail payload the mark's card uses. The wrapper is always mounted (see
802
+ * withTooltip) and the label degrades while the names are in flight: the
803
+ * COUNTS are already in the base response, so it restates the breakdown
804
+ * rather than claiming an empty list. "no changed files" is reserved for a
805
+ * landed payload that genuinely has no names.
676
806
  */
677
- function CountNames({ info }) {
807
+ function CountNames({ info, pending = false }) {
678
808
  const sections = [
679
809
  {
680
810
  label: "staged",
@@ -693,6 +823,11 @@ window.__ModuleLoader__.load({
693
823
  }
694
824
  ].filter((section) => Array.isArray(section.names) && section.names.length > 0);
695
825
  if (sections.length === 0) {
826
+ // `pending`: the names have not been fetched yet, but the COUNTS have —
827
+ // restate those rather than the false "no changed files".
828
+ if (pending) {
829
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: formatFileBreakdown(info) });
830
+ }
696
831
  return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no changed files" });
697
832
  }
698
833
  return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children:
@@ -741,27 +876,11 @@ window.__ModuleLoader__.load({
741
876
  if (value === void 0 || value === null || value === "") return;
742
877
  rows.push(cardRow(label, value));
743
878
  };
744
- add("upstream", info?.upstream === void 0 ? "none configured" : info.upstream);
745
- const ahead = info?.ahead || 0;
746
- const behind = info?.behind || 0;
747
- // The sync row speaks ONLY about the upstream it names: with no
748
- // upstream configured there are no ahead/behind counts at all (no
749
- // `# branch.ab` header), and falling back to 0/0 would claim "in
750
- // sync" — a lie the upstream row directly contradicts. Omit it; the
751
- // "none configured" row above carries the fact.
752
- if (info?.upstream !== void 0) {
753
- add("sync", ahead === 0 && behind === 0
754
- ? "in sync with upstream"
755
- : "\u2191" + ahead + " \u2193" + behind);
756
- }
757
- // The merge-state row, in WORDS (deliberately not arrows — the sync
758
- // row above already uses ↑↓ for the push axis, and two arrow pairs
759
- // with different references would be read as one). "merge" is its
760
- // label because it is the merge axis — the counterpart of "sync",
761
- // the push axis — and it stays correct whatever the default branch
762
- // is named. A BLOCKED verdict becomes the reason (review required,
763
- // checks failing, draft) so a dead-end word names the next action;
764
- // CLEAN stays silent — the action row already offers /gh merge.
879
+ // ONLY the merge axis, and it ALWAYS renders. The old upstream and sync
880
+ // rows spoke about the push axis (the branch's own remote), which the
881
+ // merge row subsumes: "1 ahead, 0 behind main" is the same fact stated
882
+ // against the branch that matters, in words rather than arrows. A row
883
+ // that can be absent is a row the reader has to reconstruct.
765
884
  const pr = info?.pr;
766
885
  const mergeParts = [];
767
886
  if (pr !== void 0 && pr !== null && pr.number !== void 0) {
@@ -771,12 +890,15 @@ window.__ModuleLoader__.load({
771
890
  : pr.state === "failing" ? "blocked: checks failing"
772
891
  : "blocked");
773
892
  } else if (pr.state === "failing") mergeParts.push("blocked: checks failing");
774
- else if (pr.mergeState !== "CLEAN" && pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
893
+ else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) mergeParts.push("ready to merge");
894
+ else if (pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
895
+ } else {
896
+ mergeParts.push("no pull request");
775
897
  }
776
898
  if (info?.mainAhead !== void 0 || info?.mainBehind !== void 0) {
777
899
  mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
778
900
  }
779
- if (mergeParts.length > 0) add("merge", mergeParts.join(" \u00B7 "));
901
+ add("merge", mergeParts.join(" \u00B7 "));
780
902
  const commits = info?.branchCommits;
781
903
  if (Array.isArray(commits) && commits.length > 0) {
782
904
  // Full-width block, not the label/value two-column row: commit lines
@@ -788,23 +910,9 @@ window.__ModuleLoader__.load({
788
910
  react_jsx_runtime.jsxs("div", {
789
911
  style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
790
912
  children:
791
- commits.map((commit) => react_jsx_runtime.jsxs("span", {
792
- style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
793
- children: [
794
- // fixed-width monospace hash cell: every description starts
795
- // on the same column, so the list reads as a table
796
- react_jsx_runtime.jsx("span", {
797
- style: {
798
- color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
799
- fontFamily: "monospace",
800
- display: "inline-block",
801
- minWidth: "68px",
802
- flex: "none"
803
- },
804
- children: (commit.sign === "+" ? "+" : commit.sign === "\u2212" ? "\u2212" : "") + commit.hash + ":"
805
- }),
806
- commit.subject + (commit.when === "" ? "" : " \u00B7 " + shortWhen(commit.when))
807
- ]
913
+ commits.map((commit) => react_jsx_runtime.jsx(CommitLine, {
914
+ commit,
915
+ sign: commit.sign === "+" ? "+" : commit.sign === "\u2212" ? "\u2212" : ""
808
916
  }, commit.hash))
809
917
  }, "branch-commits")
810
918
  );
@@ -891,11 +999,13 @@ window.__ModuleLoader__.load({
891
999
  rows.push(cardRow(label, value));
892
1000
  };
893
1001
  // The "action" row — the top line, in the card's ordinary row layout:
894
- // the gh skill invocation the checkout justifies, derived from the same
895
- // state the /gh picker uses (first sub-action wins). Plain text, no
896
- // affordance: the actionable surface is the (+) menu's /gh picker, and
897
- // this row is the read-only pointer to it.
898
- const topAction = ghSkillActions(data)[0];
1002
+ // the SERVER's ranked suggestion, and nothing else. The client's
1003
+ // extras (secondary picker entries) must never promote themselves into
1004
+ // this line: when the server says there is nothing to do — clean and
1005
+ // synced — the row is omitted, and "no suggestion" stays a suggestion.
1006
+ // Plain text, no affordance: the actionable surface is the (+) menu's
1007
+ // /gh picker, and this row is the read-only pointer to it.
1008
+ const topAction = data.next;
899
1009
  if (topAction !== void 0) {
900
1010
  // git-comment convention: the invocation, two spaces, then "# "
901
1011
  // plus what it does
@@ -920,6 +1030,24 @@ window.__ModuleLoader__.load({
920
1030
  }
921
1031
 
922
1032
  //#region composer chip (upstream additive surface: conversation.input.left)
1033
+ /**
1034
+ * The ONE copy of the hover-card degrade rule: wrap a node in the shell's
1035
+ * Tooltip when the primitive exists, and render the node bare otherwise — a
1036
+ * missing card is a smaller failure than a missing badge. `label` is the
1037
+ * thunk the Tooltip calls only when it opens, so the card's element tree
1038
+ * stays out of closed tooltips.
1039
+ *
1040
+ * ALWAYS mount the wrapper — never conditionally, once its data arrives.
1041
+ * The primitive is uncontrolled (it has no `open` prop) and opens on the
1042
+ * CHILD's mouseenter, so a wrapper mounted after the pointer is already
1043
+ * inside can never open for that visit: the pointer has to leave and
1044
+ * return. Gate the LABEL's content instead.
1045
+ */
1046
+ function withTooltip(node, label, maxWidth) {
1047
+ if (Tooltip === void 0) return node;
1048
+ return react_jsx_runtime.jsx(Tooltip, { side: "top", maxWidth, label, children: node });
1049
+ }
1050
+
923
1051
  /**
924
1052
  * Chip line in the input row: git state of the workspace the CURRENT
925
1053
  * conversation is attached to. It targets the session id and lets the node
@@ -955,7 +1083,7 @@ window.__ModuleLoader__.load({
955
1083
  const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
956
1084
  // the PR/CI token is always on the chip, so its fetch is not gated
957
1085
  const info = useGitStatus(target, { pr: true });
958
- // The card's extras cost a `log -3` plus a stash list, so they are
1086
+ // The card's extras cost a `log -10` plus a stash list, so they are
959
1087
  // fetched only once a pointer actually RESTS on the chip, then kept
960
1088
  // fresh by the same SSE path. Eagerly asking would add both invocations
961
1089
  // to every refresh — and a refresh fires on every file edit.
@@ -980,45 +1108,52 @@ window.__ModuleLoader__.load({
980
1108
  // so the extras are paid for exactly when the card that can render them
981
1109
  // is about to open. Defined inside the component: it closes over the
982
1110
  // hovered state that gates the detail fetch.
983
- const hoverableMark = (() => {
984
- const mark = react_jsx_runtime.jsx("span", {
1111
+ const hoverableMark = withTooltip(
1112
+ react_jsx_runtime.jsx("span", {
985
1113
  onPointerEnter: () => setHovered(true),
986
1114
  style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
987
1115
  children: react_jsx_runtime.jsx(StatusMark, { info })
988
- });
989
- if (Tooltip === void 0) return mark;
990
- return react_jsx_runtime.jsx(Tooltip, {
991
- side: "top",
992
- // matches the body cap: the tooltip must never be the clamp
993
- maxWidth: 600,
994
- // a function label keeps the card's element tree out of every render
995
- // until the tooltip actually opens
996
- label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
997
- children: mark
998
- });
999
- })();
1116
+ }),
1117
+ // a function label keeps the card's element tree out of every render
1118
+ // until the tooltip actually opens
1119
+ () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
1120
+ 600 // matches the body cap: the tooltip must never be the clamp
1121
+ );
1000
1122
  // the mark is an element now rather than a leading glyph in the string, so
1001
1123
  // the SAME StatusMark the sidebar row draws carries the status here too;
1002
1124
  // the container's 4px gap supplies the space the emoji's own did
1003
1125
  // The branch NAME is its own hover surface: the commits this branch adds
1004
1126
  // (BranchCommitsList), sharing the mark's lazy detail fetch. The rest of
1005
1127
  // the leading text (operation token) and the count keep their own
1006
- // surfaces. Degrades to plain text without the Tooltip primitive or
1007
- // when the branch adds nothing beyond upstream.
1128
+ // surfaces. The wrapper is always mounted (see withTooltip); without the
1129
+ // Tooltip primitive the name renders plain.
1008
1130
  const branchHover = (() => {
1009
1131
  // The branch name links to the compare view (base...branch) — the web
1010
1132
  // page of exactly what the lineage tooltip lists. Vouched http(s)
1011
1133
  // only (the node half rebuilt the URL from parsed parts); new tab,
1012
1134
  // never navigating the conversation away.
1013
1135
  const isLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
1014
- const branch = isLink
1136
+ // The sync arrows ride WITH the branch: they are the branch's standing
1137
+ // against its upstream, so the same hover (lineage) and the same link
1138
+ // (compare view, where the arrows are the diff) cover them. The ✎n
1139
+ // count stays its own surface — it answers a different question.
1140
+ const sync = formatSync(info);
1141
+ const label = info.branch + sync;
1142
+ // The Tooltip's CHILD must not change element type once the detail
1143
+ // payload lands: the primitive opens on the child's mouseenter and has
1144
+ // no `open` prop, so a node replaced under the pointer may never be
1145
+ // re-entered. The outer span is the stable hover surface; only this
1146
+ // inner node swaps (plain text -> anchor) as the compare URL arrives.
1147
+ const inner = isLink
1015
1148
  ? react_jsx_runtime.jsx("a", {
1016
1149
  href: detail.compareUrl,
1017
1150
  target: "_blank",
1018
1151
  rel: "noopener noreferrer",
1019
- "aria-label": "Compare " + info.branch + " with main on GitHub",
1020
- onPointerEnter: () => { setHovered(true); setBranchLinkHover(true); },
1152
+ "aria-label": "Compare " + info.branch + " with main on GitHub" + (sync === "" ? "" : ", " + (info.ahead || 0) + " ahead and " + (info.behind || 0) + " behind upstream"),
1153
+ onPointerEnter: () => setBranchLinkHover(true),
1021
1154
  onPointerLeave: () => setBranchLinkHover(false),
1155
+ // focus joins hover: a keyboard user needs the same underline, and
1156
+ // focus (unlike a pointer) must also start the detail fetch
1022
1157
  onFocus: () => { setHovered(true); setBranchLinkHover(true); },
1023
1158
  onBlur: () => setBranchLinkHover(false),
1024
1159
  style: {
@@ -1026,24 +1161,19 @@ window.__ModuleLoader__.load({
1026
1161
  textDecoration: branchLinkHover ? "underline" : "none",
1027
1162
  cursor: "pointer"
1028
1163
  },
1029
- children: info.branch
1164
+ children: label
1030
1165
  })
1031
- : react_jsx_runtime.jsx("span", {
1032
- onPointerEnter: () => setHovered(true),
1033
- style: { cursor: "default" },
1034
- children: info.branch
1035
- });
1036
- if (Tooltip === void 0 || detail === void 0 || detail === null) {
1037
- // mid-fetch the branch shows plain; the lineage tooltip is
1038
- // unconditional once the payload lands
1039
- return branch;
1040
- }
1041
- return react_jsx_runtime.jsx(Tooltip, {
1042
- side: "top",
1043
- maxWidth: 480,
1044
- label: () => react_jsx_runtime.jsx(BranchLineage, { info: detail }),
1045
- children: branch
1166
+ : label;
1167
+ const branch = react_jsx_runtime.jsx("span", {
1168
+ onPointerEnter: () => setHovered(true),
1169
+ style: { cursor: isLink ? "pointer" : "default" },
1170
+ children: inner
1046
1171
  });
1172
+ // ALWAYS wrapped. A wrapper mounted only once the lazy detail arrives
1173
+ // cannot open for the visit that fetched it (see the child-stability
1174
+ // note above); until the payload lands the lineage degrades to the base
1175
+ // response, the way the mark's own card already does.
1176
+ return withTooltip(branch, () => react_jsx_runtime.jsx(BranchLineage, { info: detail ?? info }), 480);
1047
1177
  })();
1048
1178
  const text = formatOperationToken(info);
1049
1179
  // The ✎n count is its OWN hover surface: a names-only tooltip (see
@@ -1051,25 +1181,71 @@ window.__ModuleLoader__.load({
1051
1181
  // either triggers it once — and degrades to a plain count on a shell
1052
1182
  // without the Tooltip primitive.
1053
1183
  const countHover = (() => {
1184
+ // ✎n only: the sync arrows moved to the branch's surface above
1185
+ const countText = formatFileCount(info);
1054
1186
  const count = react_jsx_runtime.jsx("span", {
1055
1187
  onPointerEnter: () => setHovered(true),
1056
1188
  style: { cursor: "default" },
1057
- children: formatGitSuffix(info)
1058
- });
1059
- const hasNames = (Array.isArray(detail?.untrackedNames) && detail.untrackedNames.length > 0)
1060
- || (Array.isArray(detail?.unstagedNames) && detail.unstagedNames.length > 0);
1061
- if (Tooltip === void 0 || !hasNames) {
1062
- return count;
1063
- }
1064
- return react_jsx_runtime.jsx(Tooltip, {
1065
- side: "top",
1066
- maxWidth: 480,
1067
- label: () => react_jsx_runtime.jsx(CountNames, { info: detail }),
1068
- children: count
1189
+ children: countText
1069
1190
  });
1191
+ // always wrapped, for the same reason as the branch: the primitive
1192
+ // opens on the child's mouseenter, so a wrapper added after the
1193
+ // pointer arrives never opens. While the names are in flight the label
1194
+ // restates the breakdown the base response already carries.
1195
+ const pending = detail === void 0 || detail === null;
1196
+ return withTooltip(count, () => react_jsx_runtime.jsx(CountNames, { info: detail ?? info, pending }), 480);
1070
1197
  })();
1071
1198
  const prToken = formatPrToken(info);
1072
1199
  const prUrl = prLinkUrl(info);
1200
+ // The PR token, with its own hover: the commits IN THIS PR (see
1201
+ // PrCommitsList). Built here so the link/plain cases and the tooltip
1202
+ // compose once instead of nesting a third conditional in the children.
1203
+ // The token is its OWN detail gate: resting on it (or focusing it)
1204
+ // enables the same `detail=1` fetch the mark and the branch do. Without
1205
+ // that, a pointer going straight to the token had rested on nothing that
1206
+ // fetches, so `prCommits` was never requested and this hover could not
1207
+ // open at all — the data it renders is detail-only.
1208
+ const prTokenHover = (() => {
1209
+ const token = prUrl === void 0
1210
+ ? react_jsx_runtime.jsx("span", {
1211
+ // the glyph is not the only channel: the token says what the
1212
+ // CI state IS, for anyone who cannot see it
1213
+ "aria-label": prTokenLabel(info),
1214
+ onPointerEnter: () => setHovered(true),
1215
+ style: { cursor: "default" },
1216
+ children: prToken
1217
+ })
1218
+ : react_jsx_runtime.jsx("a", {
1219
+ href: prUrl,
1220
+ // a new tab, because navigating THIS one away from the app
1221
+ // would lose the conversation; noopener/noreferrer keep the
1222
+ // opened tab from getting a handle on it
1223
+ target: "_blank",
1224
+ rel: "noopener noreferrer",
1225
+ "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
1226
+ // the chip's own colour rather than the browser's link blue /
1227
+ // visited purple, so the token still reads as one row. The
1228
+ // affordance is the pointer cursor plus an underline on hover
1229
+ // or focus, set HERE rather than left to the host stylesheet:
1230
+ // a shell that resets anchors would otherwise drop it silently.
1231
+ style: {
1232
+ color: "inherit",
1233
+ textDecoration: linkHover ? "underline" : "none",
1234
+ cursor: "pointer"
1235
+ },
1236
+ // focus joins hover — the token is keyboard-reachable now, and a
1237
+ // keyboard user needs the same "this is a link" signal
1238
+ onPointerEnter: () => { setHovered(true); setLinkHover(true); },
1239
+ onPointerLeave: () => setLinkHover(false),
1240
+ onFocus: () => { setHovered(true); setLinkHover(true); },
1241
+ onBlur: () => setLinkHover(false),
1242
+ children: prToken
1243
+ });
1244
+ // always wrapped (see the branch): while the commits are in flight the
1245
+ // label names the PR state the base response already carries
1246
+ const pending = detail === void 0 || detail === null;
1247
+ return withTooltip(token, () => react_jsx_runtime.jsx(PrCommitsList, { info: detail ?? info, pending }), 480);
1248
+ })();
1073
1249
  const chip = react_jsx_runtime.jsxs("span", {
1074
1250
  style: {
1075
1251
  display: "inline-flex",
@@ -1103,43 +1279,7 @@ window.__ModuleLoader__.load({
1103
1279
  branchHover,
1104
1280
  react_jsx_runtime.jsx("span", { key: "text", children: text }),
1105
1281
  countHover,
1106
- prToken === ""
1107
- ? null
1108
- : prUrl === void 0
1109
- ? react_jsx_runtime.jsx("span", {
1110
- key: "pr",
1111
- // the glyph is not the only channel: the token says what the
1112
- // CI state IS, for anyone who cannot see it
1113
- "aria-label": prTokenLabel(info),
1114
- children: prToken
1115
- })
1116
- : react_jsx_runtime.jsx("a", {
1117
- key: "pr",
1118
- href: prUrl,
1119
- // a new tab, because navigating THIS one away from the app
1120
- // would lose the conversation; noopener/noreferrer keep the
1121
- // opened tab from getting a handle on it
1122
- target: "_blank",
1123
- rel: "noopener noreferrer",
1124
- "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
1125
- // the chip's own colour rather than the browser's link blue /
1126
- // visited purple, so the token still reads as one row. The
1127
- // affordance is the pointer cursor plus an underline on hover
1128
- // or focus, set HERE rather than left to the host stylesheet:
1129
- // a shell that resets anchors would otherwise drop it silently.
1130
- style: {
1131
- color: "inherit",
1132
- textDecoration: linkHover ? "underline" : "none",
1133
- cursor: "pointer"
1134
- },
1135
- // focus joins hover — the token is keyboard-reachable now, and a
1136
- // keyboard user needs the same "this is a link" signal
1137
- onPointerEnter: () => setLinkHover(true),
1138
- onPointerLeave: () => setLinkHover(false),
1139
- onFocus: () => setLinkHover(true),
1140
- onBlur: () => setLinkHover(false),
1141
- children: prToken
1142
- }),
1282
+ prTokenHover,
1143
1283
  /**
1144
1284
  * The seam-absent notice: the market-install notification for the
1145
1285
  * state a blocked postinstall leaves behind — chip working, sidebar
@@ -1424,7 +1564,12 @@ window.__ModuleLoader__.load({
1424
1564
  info = await fetch("/api/git-badge?" + query).then((r) => r.json());
1425
1565
  }
1426
1566
  const actions = ghSkillActions(info);
1427
- return actions.map((action) => ({ label: "/gh " + action.args, detail: action.why, args: action.args }));
1567
+ return actions.map((action) => ({
1568
+ label: "/gh " + action.args,
1569
+ detail: action.why,
1570
+ args: action.args,
1571
+ ...action.danger === true ? { confirmation: cleanGate() } : {}
1572
+ }));
1428
1573
  } catch (error) {
1429
1574
  console.error("[dsh-git-badge] /gh options failed:", error);
1430
1575
  return [];
@@ -1667,7 +1812,7 @@ window.__ModuleLoader__.load({
1667
1812
  // Additive; the host reads apply/inject and ignores the rest. The suite
1668
1813
  // drives these to assert the REQUEST contract — which surface asks for the
1669
1814
  // expensive extras — without a browser, a fetch or a network.
1670
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, moveCategory };
1815
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, cleanGate, moveCategory };
1671
1816
  return module.exports;
1672
1817
  }
1673
1818
  });